feat(ui): context panel 2.0 - surface rail, changes-first git view, live PR surface (#2418)

* feat(ui): add context surface registry and rail switcher

* feat(ui): move git and project notes into context surfaces, embed editor file tree

* feat(ui): replace right sidebar with context surfaces, per-surface panel widths

* refactor(ui): retire legacy main-tab overlays and right-sidebar state

* feat(ui): rail polish, right-docked file tree, terminal surface

* feat(ui): move terminal into context surface, per-surface tab closing, editor empty state

* feat(ui): tune default rail order and activity dot

* fix(ui): keep context panel controls anchored during width animations

* feat(ui): lazy-follow context panel resize with window-level drag tracking

* feat(ui): panel dividers, right-dock tree icon, muted outline folder icons

* feat(ui): restructure git view into changes-first surface with standalone PR surface

- Remove commit/update/pr tabs; git view is always changes + commit
- Promote pull request to its own rail surface with shared repo context
- Move update-branch and re-integrate flows into separate dialogs
- Add PR status chip and repo actions menu to the git header row
- Seed new PR-status entries from resolved sibling remotes to avoid
  a false "checking status" state when the PR is already known
- History/graph dialog refresh button, fingerprint global identity icon,
  muted outline folder icons follow-ups

* feat(ui): progressive-disclosure PR surface with live checks and pinned chat context

- Segment the PR surface into Overview / Checks / Comments pill tabs with
  live badges; merge controls move to the status row
- Live checks segment: progress bar, per-run rows with workflow names,
  elapsed timers, expandable failures, auto-refresh while pending
- PR comments and failed checks pin as chat-context drafts (like terminal
  selections) instead of sending an immediate message; works on new-session
  drafts too
- Shared prContext cache client+server, ETag conditional requests in the
  octokit wrapper (304s bypass rate limits), extended checks aggregate
  (inProgress/queued/startedAt)
- Resolve gh-CLI auth login for merge-permission checks
- Full-width description editor with matched control heights

* fix(ui): single source of truth for PR checks and status readers

- Derive the checks aggregate from the visible run list and sync it into
  the PR-status store so bar, badges, header, and git-view chip agree
- Route PR body hydration through the shared context cache
- Git-view PR chip reads the freshest entry across remote keys

* fix(github): freshness stamps prevent stale cache responses from regressing PR state

- pr/status and pulls/context responses carry a server-side fetchedAt that
  survives cache serves
- The status store rejects responses older than the held snapshot (only
  clearing the loading flag), and the checks sync adopts the context's
  stamp so stale status polls cannot flip fresher derived checks
- Regression test for the stale-response guard

* perf(github): repo-level pull-list cache collapses per-branch PR resolution

- One pulls.list per repo per state per 45s answers every branch (10
  worktrees = 1 call, not 10 query fans); in-flight fetches coalesce
- A complete repo list makes a no-PR miss authoritative, skipping the
  per-owner head queries AND the Search API fallback (the 30/min killer)
- force refresh bypasses the repo list cache; PR create/merge/ready
  invalidate it

* perf(github): back off Search API misses per repo+branch

A branch without a PR re-searched on every poll; with >100 closed PRs the
list miss is never authoritative, so the search fallback still ran and
burned the 30/min search quota. Remember misses for 10 minutes; PR
creation clears remembered misses for the repo.

* fix(github): dedupe re-run check runs to the latest per (app, name)

listForRef returns the superseded completed run alongside its re-run;
GitHub's UI shows only the latest per name. Mirror that in both pr/status
and pulls/context so counts and run lists match github.com.

* fix(ui): address review findings on registry test, surface docs, and PR-context keys

- Rail-order test asserts against the registry itself (was stale after the
  'pr' surface landed and failed)
- surfaces DOCUMENTATION.md describes actual behavior: has-content surfaces
  hide until content exists; only multi-instance/terminal panes are
  keep-alive, singleton surfaces remount and restore from stores
- PR-context cache keys are runtime-scoped JSON tuples; invalidation
  compares the directory exactly instead of by string prefix (+ test)

* fix(ui): wrap long unbreakable tokens in check-run details

Annotation messages with long SHAs/URLs overflowed the panel; break-words
on annotation title/message/rawDetails and output summary/text, and the
expanded run body clips instead of widening the panel.

* fix(ui): busy state for context-attach buttons and honest attach labels

- 'Attach failed checks' / 'Attach all to chat' show a spinner and disable
  while the context request runs (previously nothing happened for seconds)
- Action labels/tooltips reworded from send-to-agent to attach-to-chat
  semantics across all locales

* fix(i18n): Ukrainian attach wording uses 'прикріпити' with proper cases

* fix(ui): runtime-scope PR-view remote caches, correct surfaces doc on preview

- Remote/remote-url caches in PullRequestView are keyed by runtime +
  directory so a backend switch never serves another runtime's remotes
- surfaces DOCUMENTATION.md: preview is not keep-alive; preview tabs
  remount on switch like singleton surfaces

* fix(ui): rail active color, clearer collapse icon, remove dead bottom-terminal dock

Design-review feedback on the context panel:

- Context rail: icons enlarged 16px -> 18px; the active surface is now
  highlighted with the primary color only (no background, no scale
  animation), replacing the previous scale-up effect that read as a
  resize rather than a selected state.
- Files tree: the icon-only 'collapse all folders' toolbar button now
  uses collapse-vertical instead of contract-up-down, which was easily
  mistaken for a close button. The labelled 'Collapse all' dropdown item
  in the session sidebar keeps its icon since text removes the ambiguity.
- Terminal: removed the leftover bottom-dock expand/close buttons that
  rendered in the context-panel terminal but controlled a dock that no
  longer exists (nothing toggles it anymore), so the expand button
  appeared to do nothing and duplicated the panel-header fullscreen
  control. Cleaned up the entire inert layer with it: four useUIStore
  fields (isBottomTerminalOpen/Expanded, bottomTerminalHeight,
  hasManuallyResizedBottomTerminal), five actions, their persistence,
  the MainLayout resize listener that only served the dock height, the
  dock-driven refit effect in TerminalView, and the
  terminalView.bottomDock.* keys across all 10 locale dictionaries.

Validated: ui type-check and lint clean; messages parity test (2 pass)
and useUIStore contextPanel test (13 pass) green; icon sprite
regenerated via icons:generate.

* refactor: use PR visual state for git header icon

Derives the pull request icon color from a single visual state
Covers merged, closed, draft, blocked, and open PR states
Removes conditional class handling from the git header icon
This commit is contained in:
Bohdan Triapitsyn
2026-07-27 23:07:42 +03:00
committed by GitHub
parent 005b2e61b0
commit e2fa7dbad2
67 changed files with 3767 additions and 2347 deletions
+10 -4
View File
@@ -703,13 +703,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
let previewAnnotation = 0;
let review = 0;
let terminal = 0;
let prComment = 0;
let prCheck = 0;
for (const draft of drafts) {
if (draft.source === 'preview-console') previewConsole += 1;
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
else if (draft.source === 'terminal') terminal += 1;
else if (draft.source === 'pr-comment') prComment += 1;
else if (draft.source === 'pr-check') prCheck += 1;
else review += 1;
}
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`;
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
},
[inlineDraftKey]
)
@@ -717,11 +721,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const hasDrafts = draftCount > 0;
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const terminalContextDrafts = terminalContextCount > 0
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
: [];
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
@@ -735,7 +739,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
removeInlineCommentDraft(inlineDraftTarget, draft.id);
}
}
@@ -2375,6 +2379,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<ComposerContextChips
terminalDrafts={terminalContextDrafts}
reviewCount={reviewCount}
prCommentCount={prCommentCount}
prCheckCount={prCheckCount}
previewConsoleCount={previewConsoleCount}
previewAnnotationCount={previewAnnotationCount}
draftTarget={inlineDraftTarget}
@@ -90,7 +90,6 @@ export const PendingChangesBar: React.FC = React.memo(() => {
return;
}
store.navigateToDiff(file.relativePath, openStagedDiff);
store.setRightSidebarOpen(false);
};
const fileCount = gitChangedFiles.length;
@@ -62,7 +62,6 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
}
store.navigateToDiff(relativePath, false, 'turn');
store.setRightSidebarOpen(false);
setIsExpanded(false);
};
@@ -18,12 +18,14 @@ export interface ComposerContextChipsProps {
/** Terminal selections, which show their own label and line range. */
terminalDrafts: readonly InlineCommentDraft[];
reviewCount: number;
prCommentCount: number;
prCheckCount: number;
previewConsoleCount: number;
previewAnnotationCount: number;
draftTarget: InlineCommentDraftTarget | null;
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
onRemoveReviewDrafts: () => void;
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation') => void;
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
colors: Theme['colors'];
}
@@ -34,6 +36,7 @@ function CountChip(props: {
removeLabel: string;
onRemove: () => void;
colors: Theme['colors'];
icon?: React.ReactNode;
}) {
return (
<div
@@ -43,6 +46,7 @@ function CountChip(props: {
borderColor: props.colors?.interactive?.border,
}}
>
{props.icon}
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
{props.count}
@@ -66,6 +70,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
const {
terminalDrafts,
reviewCount,
prCommentCount,
prCheckCount,
previewConsoleCount,
previewAnnotationCount,
draftTarget,
@@ -113,6 +119,28 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
/>
) : null}
{prCommentCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCommentContext')}
count={prCommentCount}
removeLabel={t('chat.chatInput.prCommentContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-comment')}
colors={colors}
icon={<Icon name="git-pull-request" className="h-3.5 w-3.5 text-muted-foreground" />}
/>
) : null}
{prCheckCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCheckContext')}
count={prCheckCount}
removeLabel={t('chat.chatInput.prCheckContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-check')}
colors={colors}
icon={<Icon name="close-circle" className="h-3.5 w-3.5 text-[var(--status-error)]" />}
/>
) : null}
{previewConsoleCount > 0 ? (
<CountChip
label={t('chat.chatInput.devServerLogs')}
@@ -1615,7 +1615,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const relativePath = getRelativePath(absolutePath, currentDirectory);
if (store.isMobile) {
store.navigateToDiff(relativePath);
store.setRightSidebarOpen(false);
return;
}
store.openContextDiff(currentDirectory, relativePath);
@@ -61,6 +61,7 @@ export const iconSpriteData = {
"code-ai": `<path d="M17.7134 10.1281L17.4668 10.6938C17.2864 11.1079 16.7136 11.1079 16.5331 10.6938L16.2866 10.1281C15.8471 9.11947 15.0555 8.31641 14.0677 7.87708L13.308 7.53922C12.8973 7.35653 12.8973 6.75881 13.308 6.57612L14.0252 6.25714C15.0384 5.80651 15.8442 4.97373 16.2761 3.93083L16.5293 3.31953C16.7058 2.89349 17.2942 2.89349 17.4706 3.31953L17.7238 3.93083C18.1558 4.97373 18.9616 5.80651 19.9748 6.25714L20.6919 6.57612C21.1027 6.75881 21.1027 7.35653 20.6919 7.53922L19.9323 7.87708C18.9445 8.31641 18.1529 9.11947 17.7134 10.1281ZM2.82843 12.0001L7.07107 16.2428L5.65685 17.657L0 12.0001L5.65685 6.34326L7.07107 7.75748L2.82843 12.0001ZM18.3429 17.6572L23.9998 12.0003L21.1714 9.17188L19.7571 10.5861L21.1714 12.0003L16.9287 16.2429L18.3429 17.6572Z" fill="currentColor"/>`,
"code-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z" fill="currentColor"/>`,
"code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`,
"collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`,
"command": `<path d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z" fill="currentColor"/>`,
"compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`,
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
@@ -102,6 +103,7 @@ export const iconSpriteData = {
"file-text": `<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z" fill="currentColor"/>`,
"file-transfer": `<path d="M15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM12 11V8L16 12L12 16V13H8V11H12Z" fill="currentColor"/>`,
"file-video": `<path d="M15 4V8H19V20H5V4H15ZM3.9985 2C3.44749 2 3 2.44405 3 2.9918V21.0082C3 21.5447 3.44476 22 3.9934 22H20.0066C20.5551 22 21 21.5489 21 20.9925L20.9997 7L16 2H3.9985ZM15.0008 11.667L10.1219 8.41435C10.0562 8.37054 9.979 8.34717 9.9 8.34717C9.6791 8.34717 9.5 8.52625 9.5 8.74717V15.2524C9.5 15.3314 9.5234 15.4086 9.5672 15.4743C9.6897 15.6581 9.9381 15.7078 10.1219 15.5852L15.0008 12.3326C15.0447 12.3033 15.0824 12.2656 15.1117 12.2217C15.2343 12.0379 15.1846 11.7895 15.0008 11.667Z" fill="currentColor"/>`,
"fingerprint": `<path d="M17.0003 13V14C17.0003 16.7696 16.3364 19.445 15.0853 21.8455L14.8585 22.2663L13.1116 21.2924C14.2716 19.2115 14.9211 16.8817 14.9935 14.4559L15.0003 14V13H17.0003ZM11.0003 10H13.0003V14L12.9948 14.3787C12.9153 17.1495 11.9645 19.7731 10.3038 21.928L10.073 22.2189L8.52406 20.9536C10.0408 19.0969 10.9145 16.8017 10.9943 14.3663L11.0003 14V10ZM12.0003 6C14.7617 6 17.0003 8.23858 17.0003 11H15.0003C15.0003 9.34315 13.6571 8 12.0003 8C10.3434 8 9.00025 9.34315 9.00025 11V14C9.00025 16.2354 8.1806 18.3444 6.72928 19.9768L6.51767 20.2067L5.06955 18.8273C6.23328 17.6056 6.92099 16.0118 6.99381 14.3027L7.00025 14V11C7.00025 8.23858 9.23883 6 12.0003 6ZM12.0003 2C16.9708 2 21.0003 6.02944 21.0003 11V14C21.0003 15.6979 20.7985 17.3699 20.4035 18.9903L20.2647 19.5285L18.3349 19.0032C18.726 17.5662 18.9475 16.0808 18.9919 14.5684L19.0003 14V11C19.0003 7.13401 15.8662 4 12.0003 4C10.4279 4 8.97663 4.51841 7.80805 5.39364L6.38308 3.96769C7.92267 2.73631 9.87547 2 12.0003 2ZM4.96794 5.38282L6.39389 6.8078C5.5635 7.91652 5.0543 9.27971 5.00431 10.7593L4.99961 10.999L5.00378 13C5.00378 14.1195 4.73991 15.2026 4.24263 16.1772L4.08648 16.4663L2.34961 15.4747C2.72889 14.8103 2.95077 14.0681 2.99539 13.2924L3.00378 13L3.00361 11C3.00025 8.87522 3.73656 6.92242 4.96794 5.38282Z" fill="currentColor"/>`,
"flashlight": `<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z" fill="currentColor"/>`,
"flask": `<path d="M15.9994 2V4H14.9994V7.24291C14.9994 8.40051 15.2506 9.54432 15.7357 10.5954L20.017 19.8714C20.3641 20.6236 20.0358 21.5148 19.2836 21.8619C19.0865 21.9529 18.8721 22 18.655 22H5.34375C4.51532 22 3.84375 21.3284 3.84375 20.5C3.84375 20.2829 3.89085 20.0685 3.98181 19.8714L8.26306 10.5954C8.74816 9.54432 8.99939 8.40051 8.99939 7.24291V4H7.99939V2H15.9994ZM13.3873 10.0012H10.6115C10.5072 10.3644 10.3823 10.7221 10.2371 11.0724L10.079 11.4335L6.12439 20H17.8734L13.9198 11.4335C13.7054 10.9691 13.5276 10.4902 13.3873 10.0012ZM10.9994 7.24291C10.9994 7.49626 10.9898 7.7491 10.9706 8.00087H13.0282C13.0189 7.87982 13.0119 7.75852 13.0072 7.63704L12.9994 7.24291V4H10.9994V7.24291Z" fill="currentColor"/>`,
"folder": `<path d="M4 5V19H20V7H11.5858L9.58579 5H4ZM12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z" fill="currentColor"/>`,
@@ -164,6 +166,7 @@ export const iconSpriteData = {
"more": `<path d="M4.5 10.5C3.675 10.5 3 11.175 3 12C3 12.825 3.675 13.5 4.5 13.5C5.325 13.5 6 12.825 6 12C6 11.175 5.325 10.5 4.5 10.5ZM19.5 10.5C18.675 10.5 18 11.175 18 12C18 12.825 18.675 13.5 19.5 13.5C20.325 13.5 21 12.825 21 12C21 11.175 20.325 10.5 19.5 10.5ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z" fill="currentColor"/>`,
"more-2": `<path d="M12 3C11.175 3 10.5 3.675 10.5 4.5C10.5 5.325 11.175 6 12 6C12.825 6 13.5 5.325 13.5 4.5C13.5 3.675 12.825 3 12 3ZM12 18C11.175 18 10.5 18.675 10.5 19.5C10.5 20.325 11.175 21 12 21C12.825 21 13.5 20.325 13.5 19.5C13.5 18.675 12.825 18 12 18ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z" fill="currentColor"/>`,
"more-2-fill": `<path d="M12 3C10.9 3 10 3.9 10 5C10 6.1 10.9 7 12 7C13.1 7 14 6.1 14 5C14 3.9 13.1 3 12 3ZM12 17C10.9 17 10 17.9 10 19C10 20.1 10.9 21 12 21C13.1 21 14 20.1 14 19C14 17.9 13.1 17 12 17ZM12 10C10.9 10 10 10.9 10 12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12C14 10.9 13.1 10 12 10Z" fill="currentColor"/>`,
"more-fill": `<path d="M5 10C3.9 10 3 10.9 3 12C3 13.1 3.9 14 5 14C6.1 14 7 13.1 7 12C7 10.9 6.1 10 5 10ZM19 10C17.9 10 17 10.9 17 12C17 13.1 17.9 14 19 14C20.1 14 21 13.1 21 12C21 10.9 20.1 10 19 10ZM12 10C10.9 10 10 10.9 10 12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12C14 10.9 13.1 10 12 10Z" fill="currentColor"/>`,
"music": `<path d="M12 13.5351V3H20V5H14V17C14 19.2091 12.2091 21 10 21C7.79086 21 6 19.2091 6 17C6 14.7909 7.79086 13 10 13C10.7286 13 11.4117 13.1948 12 13.5351ZM10 19C11.1046 19 12 18.1046 12 17C12 15.8954 11.1046 15 10 15C8.89543 15 8 15.8954 8 17C8 18.1046 8.89543 19 10 19Z" fill="currentColor"/>`,
"node-tree": `<path d="M10 2C10.5523 2 11 2.44772 11 3V7C11 7.55228 10.5523 8 10 8H8V10H13V9C13 8.44772 13.4477 8 14 8H20C20.5523 8 21 8.44772 21 9V13C21 13.5523 20.5523 14 20 14H14C13.4477 14 13 13.5523 13 13V12H8V18H13V17C13 16.4477 13.4477 16 14 16H20C20.5523 16 21 16.4477 21 17V21C21 21.5523 20.5523 22 20 22H14C13.4477 22 13 21.5523 13 21V20H7C6.44772 20 6 19.5523 6 19V8H4C3.44772 8 3 7.55228 3 7V3C3 2.44772 3.44772 2 4 2H10ZM19 18H15V20H19V18ZM19 10H15V12H19V10ZM9 4H5V6H9V4Z" fill="currentColor"/>`,
"notification-3": `<path d="M20 17H22V19H2V17H4V10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10V17ZM18 17V10C18 6.68629 15.3137 4 12 4C8.68629 4 6 6.68629 6 10V17H18ZM9 21H15V23H9V21Z" fill="currentColor"/>`,
@@ -1,159 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
const BOTTOM_DOCK_MIN_HEIGHT = 180;
const BOTTOM_DOCK_MAX_HEIGHT = 640;
const BOTTOM_DOCK_COLLAPSE_THRESHOLD = 110;
interface BottomTerminalDockProps {
isOpen: boolean;
isMobile: boolean;
children: React.ReactNode;
}
export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen, isMobile, children }) => {
const { t } = useI18n();
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const [fullscreenHeight, setFullscreenHeight] = React.useState<number | null>(null);
const [isResizing, setIsResizing] = React.useState(false);
const dockRef = React.useRef<HTMLElement | null>(null);
const startYRef = React.useRef(0);
const startHeightRef = React.useRef(bottomTerminalHeight || 300);
const standardHeight = React.useMemo(
() => Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)),
[bottomTerminalHeight],
);
React.useEffect(() => {
if (!isOpen) {
setFullscreenHeight(null);
setIsResizing(false);
}
}, [isOpen]);
React.useEffect(() => {
if (isMobile || !isOpen || !isFullscreen) {
return;
}
const updateFullscreenHeight = () => {
const parentHeight = dockRef.current?.parentElement?.getBoundingClientRect().height;
if (!parentHeight || parentHeight <= 0) {
return;
}
const next = Math.max(0, Math.round(parentHeight));
setFullscreenHeight((prev) => (prev === next ? prev : next));
};
updateFullscreenHeight();
const parent = dockRef.current?.parentElement;
if (!parent) {
return;
}
const observer = new ResizeObserver(updateFullscreenHeight);
observer.observe(parent);
return () => {
observer.disconnect();
};
}, [isFullscreen, isMobile, isOpen]);
React.useEffect(() => {
if (isMobile || !isResizing || isFullscreen) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const delta = startYRef.current - event.clientY;
const nextHeight = Math.min(
BOTTOM_DOCK_MAX_HEIGHT,
Math.max(BOTTOM_DOCK_MIN_HEIGHT, startHeightRef.current + delta)
);
setBottomTerminalHeight(nextHeight);
};
const handlePointerUp = () => {
setIsResizing(false);
const latestState = useUIStore.getState();
if (latestState.bottomTerminalHeight <= BOTTOM_DOCK_COLLAPSE_THRESHOLD) {
setBottomTerminalOpen(false);
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [isFullscreen, isMobile, isResizing, setBottomTerminalHeight, setBottomTerminalOpen]);
if (isMobile) {
return null;
}
const appliedHeight = isOpen
? (isFullscreen ? Math.max(0, fullscreenHeight ?? standardHeight) : standardHeight)
: 0;
const shouldApplyFullscreenLayout = isOpen && isFullscreen;
const handlePointerDown = (event: React.PointerEvent) => {
if (!isOpen || isFullscreen) return;
setIsResizing(true);
startYRef.current = event.clientY;
startHeightRef.current = appliedHeight;
event.preventDefault();
};
return (
<section
ref={dockRef}
className={cn(
'flex overflow-hidden border-t border-border bg-sidebar',
shouldApplyFullscreenLayout ? 'absolute inset-x-0 bottom-0 z-40' : 'relative',
isResizing ? 'transition-none' : 'transition-[height] duration-300 ease-in-out',
(!isOpen || shouldApplyFullscreenLayout) && 'border-t-0'
)}
style={shouldApplyFullscreenLayout ? {
top: 0,
} : {
height: `${appliedHeight}px`,
minHeight: `${appliedHeight}px`,
maxHeight: `${appliedHeight}px`,
}}
aria-hidden={!isOpen || (!isFullscreen && appliedHeight === 0)}
>
{isOpen && !isFullscreen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-[3px] w-full cursor-row-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="horizontal"
aria-label={t('terminalView.bottomDock.resizeAria')}
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
aria-hidden={!isOpen}
>
{children}
</div>
</section>
);
};
+400 -129
View File
@@ -5,7 +5,12 @@ import { Button } from '@/components/ui/button';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PullRequestView } from '@/components/views/PullRequestView';
import { TerminalView } from '@/components/views/TerminalView';
import { PlanView } from '@/components/views/PlanView';
import { ProjectContextPanel } from './RightSidebarTabs';
import { SidebarFilesTree } from './SidebarFilesTree';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { openExternalUrl } from '@/lib/url';
import { copyTextToClipboard } from '@/lib/clipboard';
@@ -30,6 +35,7 @@ import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat';
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import {
type PreviewElementMetadata,
isPreviewElementMetadata,
@@ -44,6 +50,7 @@ import {
const CONTEXT_PANEL_MIN_WIDTH = 380;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const RESIZE_FOLLOW_INTERVAL_MS = 100;
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
type TranslateFn = ReturnType<typeof useI18n>['t'];
const EMPTY_SESSION_TITLE_MAP = new Map<string, string>();
@@ -125,16 +132,6 @@ const getAvailablePanelWidth = (panel: HTMLElement | null): number | null => {
return parentWidth;
};
const clampWidthToAvailableSpace = (width: number, panel: HTMLElement | null): number => {
const clampedWidth = clampWidth(width);
const availableWidth = getAvailablePanelWidth(panel);
if (availableWidth === null) {
return clampedWidth;
}
return Math.min(clampedWidth, Math.max(1, availableWidth));
};
const getRelativePathLabel = (filePath: string | null, directory: string): string => {
if (!filePath) {
return '';
@@ -157,6 +154,10 @@ const getModeLabel = (
if (mode === 'plan') return t('contextPanel.mode.plan');
if (mode === 'preview') return t('contextPanel.mode.preview');
if (mode === 'browser') return t('contextPanel.mode.browser');
if (mode === 'git') return t('layout.rightSidebar.git');
if (mode === 'pr') return t('contextPanel.mode.pr');
if (mode === 'notes') return t('contextRail.surface.notes');
if (mode === 'terminal') return t('layout.mainTab.terminal');
return t('contextPanel.mode.context');
};
@@ -239,6 +240,22 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }):
return <Icon name="arrow-left-right" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'git') {
return <Icon name="git-branch" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'pr') {
return <Icon name="git-pull-request" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'notes') {
return <Icon name="sticky-note" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'terminal') {
return <Icon name="terminal-box" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'plan') {
return <Icon name="file-text" className="h-3.5 w-3.5" />;
}
@@ -262,6 +279,130 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }):
return undefined;
};
const EDITOR_TREE_MIN_WIDTH = 200;
const EDITOR_TREE_MAX_WIDTH = 480;
// The editor surface's file-tree column: docked on the right, resizable from
// its left edge, and animated open/closed like the app sidebars.
const EditorTreeColumn: React.FC<{ visible: boolean }> = ({ visible }) => {
const { t } = useI18n();
const width = useUIStore((state) => state.contextEditorTreeWidth);
const setWidth = useUIStore((state) => state.setContextEditorTreeWidth);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const liveWidthRef = React.useRef<number | null>(null);
const pointerIDRef = React.useRef<number | null>(null);
const columnRef = React.useRef<HTMLDivElement | null>(null);
const clampTreeWidth = React.useCallback((value: number) => {
return Math.min(EDITOR_TREE_MAX_WIDTH, Math.max(EDITOR_TREE_MIN_WIDTH, Math.round(value)));
}, []);
const applyLiveTreeWidth = React.useCallback((nextWidth: number) => {
const column = columnRef.current;
if (!column) {
return;
}
column.style.width = `${nextWidth}px`;
column.style.setProperty('--oc-editor-tree-width', `${nextWidth}px`);
}, []);
const handlePointerDown = (event: React.PointerEvent) => {
if (!visible) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
pointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
liveWidthRef.current = width;
event.preventDefault();
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!isResizing || pointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampTreeWidth(startWidthRef.current + delta);
if (liveWidthRef.current === nextWidth) {
return;
}
liveWidthRef.current = nextWidth;
applyLiveTreeWidth(nextWidth);
};
const handlePointerEnd = (event: React.PointerEvent) => {
if (pointerIDRef.current !== event.pointerId) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampTreeWidth(liveWidthRef.current ?? width);
pointerIDRef.current = null;
liveWidthRef.current = null;
setIsResizing(false);
setWidth(finalWidth);
};
const appliedWidth = visible ? width : 0;
return (
<div
ref={columnRef}
className={cn(
'relative h-full flex-shrink-0 overflow-hidden border-l border-border/40 bg-background will-change-[width] motion-reduce:transition-none',
!visible && 'border-l-0',
)}
style={{
width: `${isResizing ? (liveWidthRef.current ?? appliedWidth) : appliedWidth}px`,
['--oc-editor-tree-width' as string]: `${isResizing ? (liveWidthRef.current ?? width) : width}px`,
overflowX: 'clip',
transitionProperty: isResizing ? 'none' : 'width',
transitionDuration: '200ms',
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
}}
aria-hidden={!visible}
>
{visible && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
<div
className={cn(
'relative z-10 h-full shrink-0 transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
isResizing && 'pointer-events-none',
!visible && 'pointer-events-none select-none opacity-0'
)}
style={{ width: 'var(--oc-editor-tree-width)' }}
aria-hidden={!visible}
>
<SidebarFilesTree />
</div>
</div>
);
};
const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => {
if (!dedupeKey || !dedupeKey.startsWith('session:')) {
return null;
@@ -2091,6 +2232,8 @@ export const ContextPanel: React.FC = () => {
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const contextEditorTreeVisible = useUIStore((state) => state.contextEditorTreeVisible);
const toggleContextEditorTree = useUIStore((state) => state.toggleContextEditorTree);
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
const { themeMode, setThemeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
@@ -2098,7 +2241,13 @@ export const ContextPanel: React.FC = () => {
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null;
const isOpen = Boolean(panelState?.isOpen && activeTab);
const isExpanded = Boolean(isOpen && panelState?.expanded);
const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH);
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
const activeModeForWidth = activeTab?.mode ?? null;
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
const widthFallbackBase = availablePanelAreaWidth
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2);
const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase));
const chatSessionIDs = React.useMemo(() => {
const ids: string[] = [];
for (const tab of tabs) {
@@ -2111,7 +2260,6 @@ export const ContextPanel: React.FC = () => {
const sessionTitleById = useSessionTitleMap(directoryKey || undefined, chatSessionIDs);
const [isResizing, setIsResizing] = React.useState(false);
const [suppressWidthTransition, setSuppressWidthTransition] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const resizingWidthRef = React.useRef<number | null>(null);
@@ -2120,41 +2268,23 @@ export const ContextPanel: React.FC = () => {
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
const wasOpenRef = React.useRef(false);
const previousIsOpenRef = React.useRef(isOpen);
const suppressWidthTransitionFrameRef = React.useRef<number | null>(null);
const suppressWidthTransitionForFrame = React.useCallback(() => {
setSuppressWidthTransition(true);
if (suppressWidthTransitionFrameRef.current !== null) {
window.cancelAnimationFrame(suppressWidthTransitionFrameRef.current);
}
suppressWidthTransitionFrameRef.current = window.requestAnimationFrame(() => {
suppressWidthTransitionFrameRef.current = null;
setSuppressWidthTransition(false);
});
}, []);
React.useEffect(() => () => {
if (suppressWidthTransitionFrameRef.current !== null) {
window.cancelAnimationFrame(suppressWidthTransitionFrameRef.current);
}
}, []);
// Tracks the panel area width so fraction-based surface defaults stay
// proportional as the window resizes; manual widths remain fixed px.
React.useLayoutEffect(() => {
const wasOpen = previousIsOpenRef.current;
previousIsOpenRef.current = isOpen;
if (!isOpen) {
setSuppressWidthTransition(false);
const parent = panelRef.current?.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') {
return;
}
if (wasOpen) {
return;
}
const observer = new ResizeObserver(() => {
setAvailablePanelAreaWidth(parent.clientWidth || null);
});
observer.observe(parent);
setAvailablePanelAreaWidth(parent.clientWidth || null);
suppressWidthTransitionForFrame();
}, [isOpen, suppressWidthTransitionForFrame]);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!isOpen || wasOpenRef.current) {
@@ -2170,13 +2300,36 @@ export const ContextPanel: React.FC = () => {
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
// Deferred resize: reflowing the chat column and the active surface (xterm,
// editor, embedded chat iframes) on every drag frame is unavoidably janky,
// so during the drag only a ghost guide line follows the pointer and the
// real width is applied once on release (riding the width transition).
const resizeAvailableWidthRef = React.useRef<number | null>(null);
// The panel content follows the guide line lazily: the real width is
// re-applied at most every RESIZE_FOLLOW_INTERVAL_MS and the standing
// 200ms width transition smooths each step, VS Code-style.
const resizeFollowTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const applyFollowWidth = React.useCallback(() => {
resizeFollowTimerRef.current = null;
const panel = panelRef.current;
if (!panel) {
const next = resizingWidthRef.current;
if (!panel || next === null) {
return;
}
panel.style.setProperty('--oc-context-panel-width', `${next}px`);
}, []);
panel.style.setProperty('--oc-context-panel-width', `${clampWidthToAvailableSpace(nextWidth, panel)}px`);
React.useEffect(() => () => {
if (resizeFollowTimerRef.current !== null) {
clearTimeout(resizeFollowTimerRef.current);
}
}, []);
const clampWidthForDrag = React.useCallback((nextWidth: number) => {
const clamped = clampWidth(nextWidth);
const available = resizeAvailableWidthRef.current;
return available === null ? clamped : Math.min(clamped, Math.max(1, available));
}, []);
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
@@ -2184,59 +2337,86 @@ export const ContextPanel: React.FC = () => {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore; fallback listeners still handle drag
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
resizingWidthRef.current = width;
applyLiveWidth(width);
// Measure once per drag; no layout reads happen during pointermove.
resizeAvailableWidthRef.current = getAvailablePanelWidth(panelRef.current);
document.documentElement.style.cursor = 'col-resize';
event.preventDefault();
}, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]);
}, [directoryKey, isExpanded, isOpen, width]);
const handleResizeMove = React.useCallback((event: React.PointerEvent) => {
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
return;
const finishResize = React.useCallback(() => {
// Apply the final width once, letting the regular 200ms width transition
// carry the panel to the release position.
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
resizingWidthRef.current = null;
resizeAvailableWidthRef.current = null;
if (resizeFollowTimerRef.current !== null) {
clearTimeout(resizeFollowTimerRef.current);
resizeFollowTimerRef.current = null;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampWidthToAvailableSpace(startWidthRef.current + delta, panelRef.current);
if (resizingWidthRef.current === nextWidth) {
return;
document.documentElement.style.cursor = '';
if (directoryKey && activeModeForWidth) {
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
}
resizingWidthRef.current = nextWidth;
applyLiveWidth(nextWidth);
}, [applyLiveWidth, isResizing]);
const handleResizeEnd = React.useCallback((event: React.PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampWidthToAvailableSpace(resizingWidthRef.current ?? width, panelRef.current);
suppressWidthTransitionForFrame();
applyLiveWidth(finalWidth);
resizingWidthRef.current = finalWidth;
setContextPanelWidth(directoryKey, finalWidth);
setIsResizing(false);
activeResizePointerIDRef.current = null;
}, [applyLiveWidth, directoryKey, setContextPanelWidth, suppressWidthTransitionForFrame, width]);
}, [activeModeForWidth, clampWidthForDrag, directoryKey, setContextPanelWidth, width]);
// Window-level drag listeners: tracking the pointer via the 3px handle and
// pointer capture is unreliable (capture can fail over iframes and a missed
// pointerup leaves the drag stuck), so while resizing the whole window
// tracks the pointer and any release/cancel/blur ends the drag.
React.useEffect(() => {
if (!isResizing) {
return;
}
const handleMove = (event: PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampWidthForDrag(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
if (resizeFollowTimerRef.current === null) {
resizeFollowTimerRef.current = setTimeout(applyFollowWidth, RESIZE_FOLLOW_INTERVAL_MS);
}
};
const handleUp = (event: PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId) {
return;
}
finishResize();
};
const handleWindowBlur = () => {
finishResize();
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
window.addEventListener('blur', handleWindowBlur);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
window.removeEventListener('blur', handleWindowBlur);
};
}, [applyFollowWidth, clampWidthForDrag, finishResize, isResizing]);
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
document.documentElement.style.cursor = '';
}
}, [isResizing]);
@@ -2490,7 +2670,16 @@ export const ContextPanel: React.FC = () => {
postEmbeddedVisibilityToChats();
}, [darkThemeId, lightThemeId, postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
const tabItems = React.useMemo(() => tabs.map((tab) => {
// The rail switches between surfaces (modes); the in-panel strip only lists
// instances of the active multi-instance surface (open files, split chats,
// preview targets).
const isMultiInstanceMode = activeTab?.mode === 'file' || activeTab?.mode === 'chat' || activeTab?.mode === 'preview';
const activeModeTabs = React.useMemo(
() => (activeTab ? tabs.filter((tab) => tab.mode === activeTab.mode) : []),
[activeTab, tabs],
);
const tabItems = React.useMemo(() => activeModeTabs.map((tab) => {
const rawLabel = getTabLabel(tab, sessionTitleById, t);
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
@@ -2501,10 +2690,16 @@ export const ContextPanel: React.FC = () => {
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
};
}), [effectiveDirectory, sessionTitleById, t, tabs]);
}), [activeModeTabs, effectiveDirectory, sessionTitleById, t]);
const activeNonChatContent = activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'git'
? <GitView isActive={isOpen} />
: activeTab?.mode === 'pr'
? <PullRequestView />
: activeTab?.mode === 'notes'
? <ProjectContextPanel />
: activeTab?.mode === 'plan'
? <PlanView targetPath={activeTab.targetPath} />
: activeTab?.mode === 'preview'
@@ -2529,41 +2724,72 @@ export const ContextPanel: React.FC = () => {
() => tabs.filter((tab) => tab.mode === 'diff'),
[tabs],
);
const hasTerminalTab = React.useMemo(
() => tabs.some((tab) => tab.mode === 'terminal'),
[tabs],
);
const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane;
const hasFileTabs = React.useMemo(
() => tabs.some((tab) => tab.mode === 'file'),
[tabs],
);
const hasOpenEditorFile = React.useMemo(
() => tabs.some((tab) => tab.mode === 'file' && tab.targetPath),
[tabs],
);
const isFileTabActive = activeTab?.mode === 'file';
const header = (
<header className="flex h-10 items-stretch border-b border-transparent">
<SortableTabsStrip
items={tabItems}
activeId={activeTab?.id ?? null}
onSelect={(tabID) => {
if (!directoryKey) {
return;
}
setActiveContextPanelTab(directoryKey, tabID);
}}
onClose={(tabID) => {
if (!directoryKey) {
return;
}
closeContextPanelTab(directoryKey, tabID);
}}
onReorder={(activeTabID, overTabID) => {
if (!directoryKey) {
return;
}
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
}}
layoutMode="scrollable"
variant="default"
/>
<header className="flex h-10 items-stretch border-b border-border/40">
{isMultiInstanceMode ? (
<SortableTabsStrip
items={tabItems}
activeId={activeTab?.id ?? null}
onSelect={(tabID) => {
if (!directoryKey) {
return;
}
setActiveContextPanelTab(directoryKey, tabID);
}}
onClose={(tabID) => {
if (!directoryKey) {
return;
}
closeContextPanelTab(directoryKey, tabID);
}}
onReorder={(activeTabID, overTabID) => {
if (!directoryKey) {
return;
}
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
}}
layoutMode="scrollable"
variant="default"
/>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3">
{activeTab ? getTabIcon(activeTab) : null}
<span className="truncate typography-ui-label text-foreground">
{activeTab ? getModeLabel(activeTab.mode, t) : null}
</span>
</div>
)}
<div className="flex items-center gap-1 px-1.5">
{isFileTabActive ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={toggleContextEditorTree}
className="h-7 w-7 p-0"
title={t('contextRail.editorTree.toggle')}
aria-label={t('contextRail.editorTree.toggle')}
aria-pressed={contextEditorTreeVisible}
>
<Icon name="layout-right" className="h-3.5 w-3.5" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
@@ -2590,28 +2816,28 @@ export const ContextPanel: React.FC = () => {
</header>
);
// width/min/max stay interpolable across open/close (no instant min/max
// jumps) so the 200ms width transition matches the sidebars.
const panelStyle: React.CSSProperties = !isOpen
? {
['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`,
['--oc-context-panel-width' as string]: `${width}px`,
width: 0,
minWidth: 0,
maxWidth: 0,
opacity: 0,
overflow: 'hidden',
visibility: 'hidden',
maxWidth: '100%',
overflowX: 'clip',
}
: isExpanded
? {
['--oc-context-panel-width' as string]: '100%',
width: '100%',
minWidth: '100%',
// px, not '100%': px↔% width changes do not interpolate, which
// would make the expand/collapse width snap instead of animating.
['--oc-context-panel-width' as string]: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%',
width: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%',
maxWidth: '100%',
}
: {
width: 'min(var(--oc-context-panel-width), 100%)',
minWidth: `min(${CONTEXT_PANEL_MIN_WIDTH}px, 100%)`,
maxWidth: '100%',
['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`,
overflowX: 'clip',
['--oc-context-panel-width' as string]: `${width}px`,
};
return (
@@ -2622,16 +2848,29 @@ export const ContextPanel: React.FC = () => {
inert={!isOpen || undefined}
className={cn(
'flex min-h-0 flex-col overflow-hidden bg-background',
!isExpanded && 'border-l border-border/40',
// Right-anchored while expanded: `inset-0` would teleport the left
// edge instantly (position does not transition), so only the width
// animates and the panel grows leftwards from its docked position.
isExpanded
? 'absolute inset-0 z-20 min-w-0'
? 'absolute inset-y-0 right-0 z-20 min-w-0'
: 'relative h-full flex-shrink-0',
!isOpen && 'pointer-events-none',
isResizing || !isOpen || suppressWidthTransition ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
'will-change-[width] motion-reduce:transition-none',
'transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]'
)}
onKeyDownCapture={handlePanelKeyDownCapture}
style={panelStyle}
>
{/* Painted divider instead of border-l: a real border eats 1px of the
content box only while collapsed, shifting the header controls by
1px between the collapsed and expanded states. */}
{isOpen && !isExpanded && (
<div aria-hidden="true" className="absolute left-0 top-0 z-40 h-full w-px bg-border/40" />
)}
{/* Divider between the panel and the icon rail on its right. */}
{isOpen && (
<div aria-hidden="true" className="absolute right-0 top-0 z-40 h-full w-px bg-border/40" />
)}
{!isExpanded && (
<div
className={cn(
@@ -2639,19 +2878,45 @@ export const ContextPanel: React.FC = () => {
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 shrink-0 flex-col duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
// Width animates in sync with the panel (surface switches, resize
// release); during the drag itself nothing resizes — only the ghost
// guide line moves.
'transition-[width,opacity]',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
// px in the expanded state too: px↔% width changes cannot interpolate,
// so the header controls would snap instead of riding the animation.
style={{
width: isExpanded
? (availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%')
: 'var(--oc-context-panel-width)',
}}
aria-hidden={!isOpen}
>
{header}
<div className={cn('relative min-h-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}>
{hasFileTabs ? (
<div className={cn('absolute inset-0', isFileTabActive ? 'block' : 'hidden')}>
<FilesView mode="editor-only" />
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
<div className="h-full min-w-0 flex-1">
{hasOpenEditorFile ? (
<FilesView mode="editor-only" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.editorEmpty.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.editorEmpty.description')}</div>
</div>
)}
</div>
<EditorTreeColumn visible={contextEditorTreeVisible} />
</div>
) : null}
{chatTabs.map((tab) => {
@@ -2720,7 +2985,13 @@ export const ContextPanel: React.FC = () => {
/>
</div>
))}
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' ? activeNonChatContent : null}
{hasTerminalTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'terminal' ? 'block' : 'hidden')}>
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} />
</div>
) : null}
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' ? activeNonChatContent : null}
</div>
</div>
</aside>
);
@@ -0,0 +1,176 @@
import React from 'react';
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
import {
sortContextSurfaces,
type ContextSurfaceDescriptor,
} from '@/lib/surfaces/registry';
import { cn } from '@/lib/utils';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
const RAIL_TOOLTIP_DELAY_MS = 150;
const EMPTY_TABS: never[] = [];
type RailItemProps = {
surface: ContextSurfaceDescriptor;
isActive: boolean;
showActivityDot: boolean;
label: string;
description: string;
onSelect: (surface: ContextSurfaceDescriptor) => void;
};
const ContextPanelRailItem: React.FC<RailItemProps> = ({
surface,
isActive,
showActivityDot,
label,
description,
onSelect,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: surface.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn('relative', isDragging && 'z-10 opacity-70')}
>
<Tooltip delayDuration={RAIL_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
{...attributes}
{...listeners}
onClick={() => onSelect(surface)}
aria-label={label}
aria-pressed={isActive}
className={cn(
'flex h-9 w-9 touch-none select-none items-center justify-center rounded-md transition-colors',
isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
{showActivityDot ? (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
/>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
<div className="flex flex-col gap-0.5">
<span>{label}</span>
<span className="typography-micro text-muted-foreground">{description}</span>
</div>
</TooltipContent>
</Tooltip>
</div>
);
};
export const ContextPanelRail: React.FC = () => {
const { t } = useI18n();
const effectiveDirectory = useEffectiveDirectory();
const directoryKey = effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : '';
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const gitStatus = useGitStatus(directoryKey || null);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
);
const tabs = panelState?.tabs ?? EMPTY_TABS;
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? null;
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
const changedFilesCount = gitStatus?.files.length ?? 0;
// Content-driven surfaces are hidden (not disabled) until content exists;
// an existing tab keeps them visible even if the content source went away.
const surfaces = React.useMemo(() => {
return sortContextSurfaces(contextRailOrder).filter((surface) => {
if (surface.id === 'plan' && !planModeEnabled) {
return false;
}
if (surface.availability === 'has-content') {
return tabs.some((tab) => tab.mode === surface.mode);
}
return true;
});
}, [contextRailOrder, planModeEnabled, tabs]);
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const orderedIds = sortContextSurfaces(useUIStore.getState().contextRailOrder).map((surface) => surface.id);
const fromIndex = orderedIds.indexOf(active.id as (typeof orderedIds)[number]);
const toIndex = orderedIds.indexOf(over.id as (typeof orderedIds)[number]);
if (fromIndex === -1 || toIndex === -1) {
return;
}
setContextRailOrder(arrayMove(orderedIds, fromIndex, toIndex));
}, [setContextRailOrder]);
if (!directoryKey) {
return null;
}
return (
<nav
aria-label={t('contextRail.aria.rail')}
className="flex h-full w-11 flex-shrink-0 flex-col items-center gap-1 bg-background py-2"
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
{surfaces.map((surface) => (
<ContextPanelRailItem
key={surface.id}
surface={surface}
isActive={activeMode === surface.mode}
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
label={t(surface.labelKey)}
description={t(surface.descriptionKey)}
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
/>
))}
</SortableContext>
</DndContext>
</nav>
);
};
+5 -101
View File
@@ -721,12 +721,8 @@ export const Header: React.FC<HeaderProps> = ({
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar);
const openContextOverview = useUIStore((state) => state.openContextOverview);
const openContextPlan = useUIStore((state) => state.openContextPlan);
const openContextBrowser = useUIStore((state) => state.openContextBrowser);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
@@ -1479,6 +1475,7 @@ export const Header: React.FC<HeaderProps> = ({
const isContextPanelActive = activeContextMode === 'context';
const handleOpenContextPlan = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
@@ -1494,39 +1491,6 @@ export const Header: React.FC<HeaderProps> = ({
openContextPlan(directory);
}, [closeContextPanel, openContextPlan, openDirectory]);
const handleOpenContextChanges = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'diff') {
closeContextPanel(directory);
return;
}
openContextPanelTab(directory, { mode: 'diff', stagedDiff: false });
}, [closeContextPanel, openContextPanelTab, openDirectory]);
const handleOpenContextBrowser = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'browser') {
closeContextPanel(directory);
return;
}
openContextBrowser(directory);
}, [closeContextPanel, openContextBrowser, openDirectory]);
const isContextPlanActive = activeContextMode === 'plan';
const isContextChangesActive = activeContextMode === 'diff';
const isContextBrowserActive = activeContextMode === 'browser';
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS;
@@ -1764,8 +1728,10 @@ export const Header: React.FC<HeaderProps> = ({
}, [shortcutOverrides]);
useEffect(() => {
// Project actions may intentionally promote the terminal to the desktop main view.
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) {
// Project actions may intentionally promote the terminal to the desktop
// main view, and diagram clicks open the diagram viewer; every other
// legacy main tab now lives in the context panel on desktop.
if (!isMobile && activeMainTab !== 'chat' && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') {
setActiveMainTab('chat');
}
}, [activeMainTab, isMobile, setActiveMainTab]);
@@ -1991,48 +1957,8 @@ export const Header: React.FC<HeaderProps> = ({
return <React.Fragment key={tab.id}>{tabButton}</React.Fragment>;
};
const desktopChangesPanelAction = !isVSCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t('header.actions.toggleChangesPanelAria')}
aria-pressed={isContextChangesActive}
onClick={handleOpenContextChanges}
className={desktopHeaderIconButtonClass}
>
<span className="relative h-5 w-5 overflow-hidden rounded-[2px]">
<span className="absolute left-[4px] top-[4px] h-3 w-[5px] bg-[var(--status-error)]/25" />
<span className="absolute right-[4px] top-[4px] h-3 w-[5px] bg-[var(--status-success)]/25" />
<Icon name="layout-column" className="absolute inset-0 h-5 w-5" />
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('header.actions.toggleChangesPanel')}</p>
</TooltipContent>
</Tooltip>
) : null;
const desktopSidebarActions = (
<>
{showPlanTab && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t('header.actions.openPlanAria')}
onClick={handleOpenContextPlan}
className={cn(desktopHeaderIconButtonClass, isContextPlanActive && 'bg-[var(--interactive-hover)]')}
>
<Icon name="file-text" className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('header.actions.planWithShortcut', { shortcut: shortcutLabel('toggle_context_plan') })}</p>
</TooltipContent>
</Tooltip>
)}
<OpenInAppButton directory={actionDirectory} className="mr-1" />
<DesktopServicesMenu
isDesktopApp={isDesktopApp}
@@ -2069,27 +1995,6 @@ export const Header: React.FC<HeaderProps> = ({
onOpenRemoteUpdate={openRemoteInstanceUpdate}
timeFormatPreference={timeFormatPreference}
/>
<HeaderIconActionButton
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
ariaLabel={t('header.actions.toggleTerminalPanelAria')}
onClick={toggleBottomTerminal}
Icon={'terminal-box'}
/>
{!isMobile ? (
<HeaderIconActionButton
title={t('contextPanel.browser.open')}
ariaLabel={t('contextPanel.browser.open')}
onClick={handleOpenContextBrowser}
pressed={isContextBrowserActive}
Icon={'global'}
/>
) : null}
<HeaderIconActionButton
title={t('header.actions.rightSidebarWithShortcut', { shortcut: shortcutLabel('toggle_right_sidebar') })}
ariaLabel={t('header.actions.toggleRightSidebarAria')}
onClick={toggleRightSidebar}
Icon={'layout-right'}
/>
<DesktopGitHubControl
isMobile={isMobile}
githubAuthStatus={githubAuthStatus}
@@ -2192,7 +2097,6 @@ export const Header: React.FC<HeaderProps> = ({
percentIconClassName="h-4.5 w-4.5"
/>
) : null}
{desktopChangesPanelAction}
<HeaderIconActionButton
visible={showMiniChatHeaderAction}
title={isNewSessionDraftOpen ? t('header.actions.newMiniChat') : t('header.actions.openSessionMiniChat')}
+15 -157
View File
@@ -1,13 +1,12 @@
import React, { useRef, useEffect } from 'react';
import { animate, motion, useMotionValue } from 'motion/react';
import { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar } from './Sidebar';
import { SidebarTopBar } from './SidebarTopBar';
import { TitlebarLeftControls } from './TitlebarLeftControls';
import { RightSidebar } from './RightSidebar';
import { ProjectContextPanel, RightSidebarTabs } from './RightSidebarTabs';
import { ProjectContextPanel } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel';
import { ContextPanelRail } from './ContextPanelRail';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { CommandPalette } from '../ui/CommandPalette';
import { HelpDialog } from '../ui/HelpDialog';
@@ -41,15 +40,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
export const MainLayout: React.FC = () => {
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700;
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen);
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
@@ -58,9 +49,7 @@ export const MainLayout: React.FC = () => {
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
const { isMobile, isTablet } = useDeviceInfo();
const rightSidebarAutoClosedRef = React.useRef(false);
const bottomTerminalAutoClosedRef = React.useRef(false);
const { isMobile } = useDeviceInfo();
const mobilePanelsResetRef = React.useRef(false);
// Mobile drawer state
@@ -162,10 +151,7 @@ export const MainLayout: React.FC = () => {
mobilePanelsResetRef.current = true;
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
if (useUIStore.getState().isRightSidebarOpen) {
setRightSidebarOpen(false);
}
}, [isMobile, setMobileSessionPanelOpen, setRightSidebarOpen]);
}, [isMobile, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
@@ -214,10 +200,7 @@ export const MainLayout: React.FC = () => {
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
if (isRightSidebarOpen) {
setRightSidebarOpen(false);
}
}, [isMobile, isSettingsDialogOpen, isRightSidebarOpen, setMobileSessionPanelOpen, setRightSidebarOpen]);
}, [isMobile, isSettingsDialogOpen, setMobileSessionPanelOpen]);
useUpdatePolling();
@@ -228,127 +211,6 @@ export const MainLayout: React.FC = () => {
}
}, [isMobile, setIsMobile]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let frameId: number | undefined;
const handleResize = () => {
if (frameId !== undefined) {
return;
}
frameId = window.requestAnimationFrame(() => {
frameId = undefined;
useUIStore.getState().updateProportionalSidebarWidths();
});
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (frameId !== undefined) {
window.cancelAnimationFrame(frameId);
}
};
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let frameId: number | undefined;
const handleResponsivePanels = () => {
const state = useUIStore.getState();
const width = window.innerWidth;
const height = window.innerHeight;
// Touch devices frequently resize when the on-screen keyboard opens.
// Treat panel auto-collapse/restore as desktop-only so keyboard
// viewport changes do not churn drawer or terminal layout state.
if (!isMobile && !isTablet) {
const shouldCloseRightSidebar = width < RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH;
const canAutoOpenRightSidebar = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH;
if (shouldCloseRightSidebar) {
if (state.isRightSidebarOpen) {
setRightSidebarOpen(false);
rightSidebarAutoClosedRef.current = true;
}
} else if (canAutoOpenRightSidebar && rightSidebarAutoClosedRef.current) {
setRightSidebarOpen(true);
rightSidebarAutoClosedRef.current = false;
}
const shouldCloseBottomTerminal =
height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT;
const canAutoOpenBottomTerminal =
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
if (shouldCloseBottomTerminal) {
if (state.isBottomTerminalOpen) {
setBottomTerminalOpen(false);
bottomTerminalAutoClosedRef.current = true;
}
} else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) {
setBottomTerminalOpen(true);
bottomTerminalAutoClosedRef.current = false;
}
}
};
const handleResize = () => {
if (frameId !== undefined) {
return;
}
frameId = window.requestAnimationFrame(() => {
frameId = undefined;
handleResponsivePanels();
});
};
handleResponsivePanels();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (frameId !== undefined) {
window.cancelAnimationFrame(frameId);
}
};
}, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const unsubscribe = useUIStore.subscribe((state, prevState) => {
const width = window.innerWidth;
const height = window.innerHeight;
const rightCanAutoOpen = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH;
const bottomCanAutoOpen =
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
if (state.isRightSidebarOpen !== prevState.isRightSidebarOpen && rightCanAutoOpen) {
rightSidebarAutoClosedRef.current = false;
}
if (state.isBottomTerminalOpen !== prevState.isBottomTerminalOpen && bottomCanAutoOpen) {
bottomTerminalAutoClosedRef.current = false;
}
});
return () => {
unsubscribe();
};
}, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]);
const handleToggleMobileRightDrawer = React.useCallback(() => {
if (mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
@@ -357,6 +219,12 @@ export const MainLayout: React.FC = () => {
}, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]);
const secondaryView = React.useMemo(() => {
// Desktop surfaces live in the context panel; the only full-view
// overlays left there are the terminal (promoted by project actions)
// and the diagram viewer. Mobile keeps the full tab set.
if (!isMobile && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') {
return null;
}
switch (activeMainTab) {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
@@ -375,7 +243,7 @@ export const MainLayout: React.FC = () => {
default:
return null;
}
}, [activeMainTab, mobileRightSidebarOpen]);
}, [activeMainTab, isMobile, mobileRightSidebarOpen]);
const isChatActive = activeMainTab === 'chat';
@@ -539,20 +407,10 @@ export const MainLayout: React.FC = () => {
<ContextPanel />
</div>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen && activeMainTab !== 'terminal'} isMobile={isMobile}>
{isBottomTerminalOpen && activeMainTab !== 'terminal' ? (
<ErrorBoundary>
<TerminalView />
</ErrorBoundary>
) : null}
</BottomTerminalDock>
</div>
<RightSidebar
isOpen={isRightSidebarOpen}
className="bg-background border-t border-border/50"
>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
<div className="border-t border-border/50" data-page-scroll-lock="true">
<ErrorBoundary><ContextPanelRail /></ErrorBoundary>
</div>
</div>
</div>
</div>
@@ -156,7 +156,6 @@ export const ProjectActionsButton = ({
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const terminalShell = useUIStore((state) => state.terminalShell);
const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell));
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -394,7 +393,7 @@ export const ProjectActionsButton = ({
setTabIconKey(normalizedDirectory, tabId, action.icon || 'play');
setActiveTab(normalizedDirectory, tabId);
if (options.revealTerminal !== false) {
setBottomTerminalOpen(true);
useUIStore.getState().openContextPanelTab(normalizedDirectory, { mode: 'terminal' });
}
const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory);
@@ -408,7 +407,6 @@ export const ProjectActionsButton = ({
ensureDirectory,
normalizedDirectory,
setActiveTab,
setBottomTerminalOpen,
setTabIconKey,
setTabLabel,
t,
@@ -540,7 +538,7 @@ export const ProjectActionsButton = ({
store.updateProjectActionRunStatus(key, 'running');
if (run) {
store.setActiveTab(run.directory, run.tabId);
useUIStore.getState().setBottomTerminalOpen(true);
useUIStore.getState().openContextPanelTab(run.directory, { mode: 'terminal' });
}
delete previewWaitTimeoutByRunKeyRef.current[key];
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
@@ -1,160 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore, RIGHT_SIDEBAR_MIN_WIDTH, RIGHT_SIDEBAR_MAX_WIDTH } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
interface RightSidebarProps {
isOpen: boolean;
children: React.ReactNode;
className?: string;
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, className }) => {
const { t } = useI18n();
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH);
const resizingWidthRef = React.useRef<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const sidebarRef = React.useRef<HTMLElement | null>(null);
const clampRightSidebarWidth = React.useCallback((value: number) => {
return Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, value));
}, []);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const sidebar = sidebarRef.current;
if (!sidebar) {
return;
}
sidebar.style.width = `${nextWidth}px`;
sidebar.style.minWidth = `${nextWidth}px`;
sidebar.style.maxWidth = `${nextWidth}px`;
sidebar.style.setProperty('--oc-right-sidebar-width', `${nextWidth}px`);
}, []);
const openWidth = Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH));
const appliedWidth = isOpen ? openWidth : 0;
React.useLayoutEffect(() => {
if (isResizing) {
return;
}
const sidebar = sidebarRef.current;
if (!sidebar) {
return;
}
sidebar.style.minWidth = '';
sidebar.style.maxWidth = '';
}, [isOpen, isResizing, openWidth]);
const handlePointerDown = (event: React.PointerEvent) => {
if (!isOpen) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = appliedWidth;
resizingWidthRef.current = appliedWidth;
applyLiveWidth(appliedWidth);
event.preventDefault();
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampRightSidebarWidth(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
applyLiveWidth(nextWidth);
};
const handlePointerEnd = (event: React.PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampRightSidebarWidth(resizingWidthRef.current ?? appliedWidth);
activeResizePointerIDRef.current = null;
resizingWidthRef.current = null;
setIsResizing(false);
setRightSidebarWidth(finalWidth);
};
const currentWidth = isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth;
return (
<aside
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-l border-border/40 will-change-[width] motion-reduce:transition-none',
'bg-sidebar',
!isOpen && 'border-l-0',
className,
)}
style={{
width: `${currentWidth}px`,
['--oc-right-sidebar-width' as string]: `${isResizing ? currentWidth : openWidth}px`,
overflowX: 'clip',
transitionProperty: isResizing ? 'none' : 'width',
transitionDuration: '200ms',
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar.resize.rightPanelAria')}
/>
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 shrink-0 flex-col pt-2 transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
isResizing && 'pointer-events-none',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
style={{ width: 'var(--oc-right-sidebar-width)' }}
aria-hidden={!isOpen}
>
{isOpen ? children : null}
</div>
</aside>
);
};
@@ -1,72 +1,10 @@
import React from 'react';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel';
import { GitView } from '@/components/views/GitView';
import { Icon } from "@/components/icon/Icon";
import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SidebarFilesTree } from './SidebarFilesTree';
type RightTab = 'git' | 'files' | 'context';
const isRightTab = (value: string): value is RightTab =>
value === 'git' || value === 'files' || value === 'context';
const RIGHT_TAB_FALLBACK: RightTab = 'files';
const isBrowserActive = (): boolean => {
if (typeof document !== 'undefined' && document.hidden) return false;
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
return true;
};
/**
* Keeps git status fresh while the right sidebar's Git tab is the visible
* consumer. Replaces the GitPollingProvider removed in commit b2d5ccb4.
*
* Gating rules (mirror the right-sidebar render policy):
* - sidebar must be open
* - right tab must be 'git' (otherwise GitView is not the visible consumer)
* - main tab must not be 'git' (otherwise secondaryView's GitView handles
* refresh and this poll would duplicate work)
* - browser must be visible + online
*
* Any condition flip resets the interval so the next tick starts fresh.
*/
function useRightSidebarGitSync(
directory: string | undefined,
isSidebarOpen: boolean,
rightTab: RightTab | undefined,
mainTab: string | undefined
) {
const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus);
const shouldPoll = Boolean(
directory && git && isSidebarOpen && rightTab === 'git' && mainTab !== 'git'
);
React.useEffect(() => {
if (!shouldPoll || !directory || !git) return;
void ensureStatus(directory, git);
const POLL_INTERVAL = 10_000;
const id = window.setInterval(() => {
if (!isBrowserActive()) return;
void ensureStatus(directory, git);
}, POLL_INTERVAL);
return () => window.clearInterval(id);
}, [shouldPoll, directory, git, ensureStatus]);
}
import { formatDirectoryName } from '@/lib/utils';
export const ProjectContextPanel: React.FC = () => {
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
@@ -117,95 +55,3 @@ export const ProjectContextPanel: React.FC = () => {
</div>
);
};
export const RightSidebarTabs: React.FC = () => {
const { t } = useI18n();
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const directory = useEffectiveDirectory();
useRightSidebarGitSync(directory, isRightSidebarOpen, rightSidebarTab, activeMainTab);
// When the main view already hosts a right-tab equivalent (e.g. main tab
// 'git' renders GitView in the secondary slot), the right sidebar's
// matching tab is hidden to avoid two live GitView instances running
// effects. The map is small and stable; expand it if more shared
// secondary/right views are added.
const hiddenRightTab: RightTab | null =
activeMainTab === 'git'
? 'git'
: activeMainTab === 'context'
? 'context'
: null;
// Persisted right sidebar tab can be stale across main-tab switches (e.g.
// user opened main 'git' while right tab was 'git'). Snap to the fallback
// so the visible tab never equals the hidden one.
React.useEffect(() => {
if (hiddenRightTab && rightSidebarTab === hiddenRightTab) {
setRightSidebarTab(RIGHT_TAB_FALLBACK);
}
}, [hiddenRightTab, rightSidebarTab, setRightSidebarTab]);
const tabItems = React.useMemo(() => [
{
id: 'git',
label: t('layout.rightSidebar.git'),
icon: <Icon name="git-branch" className="h-3.5 w-3.5" />,
},
{
id: 'files',
label: t('layout.rightSidebar.files'),
icon: <Icon name="folder-3" className="h-3.5 w-3.5" />,
},
{
id: 'context',
label: t('layout.rightSidebar.context'),
icon: <Icon name="file-list-2" className="h-3.5 w-3.5" />,
},
], [t]);
const visibleTabItems = React.useMemo(
() => (hiddenRightTab ? tabItems.filter((item) => item.id !== hiddenRightTab) : tabItems),
[tabItems, hiddenRightTab]
);
const isRightGitTabActive = isRightSidebarOpen && rightSidebarTab === 'git' && hiddenRightTab !== 'git';
const handleTabSelect = React.useCallback(
(tabID: string) => {
if (isRightTab(tabID)) {
setRightSidebarTab(tabID);
}
},
[setRightSidebarTab]
);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div className="h-9 bg-background pt-1 px-2">
<SortableTabsStrip
items={visibleTabItems}
activeId={rightSidebarTab}
onSelect={handleTabSelect}
layoutMode="fit"
variant="active-pill"
className="h-full"
/>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className={cn('h-full', rightSidebarTab !== 'git' && 'hidden')}>
<GitView isActive={isRightGitTabActive} />
</div>
<div className={cn('h-full', rightSidebarTab !== 'files' && 'hidden')}>
<SidebarFilesTree />
</div>
<div className={cn('h-full', rightSidebarTab !== 'context' && 'hidden')}>
<ProjectContextPanel />
</div>
</div>
</div>
);
};
@@ -344,9 +344,9 @@ const FileRow: React.FC<FileRowProps> = ({
>
{isDir ? (
isExpanded ? (
<Icon name="folder-open-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
<Icon name="folder-open" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : (
<Icon name="folder-3-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
<Icon name="folder-3" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
)
) : (
getFileIcon(node.path, node.extension)
@@ -516,6 +516,7 @@ export const SidebarFilesTree: React.FC = () => {
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath);
const collapseAllExpandedPaths = useFilesViewTabsStore((state) => state.collapseAllExpandedPaths);
const contextTabs = useUIStore((state) => (root ? (state.contextPanelByDirectory[root]?.tabs ?? EMPTY_CONTEXT_TABS) : EMPTY_CONTEXT_TABS));
const openContextFilePaths = React.useMemo(() => new Set(
contextTabs
@@ -1077,30 +1078,8 @@ export const SidebarFilesTree: React.FC = () => {
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
<div className="relative min-w-0 flex-1">
<Icon name="search" className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('sidebarFilesTree.search.placeholder')}
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 ? (
<button
type="button"
aria-label={t('sidebarFilesTree.search.clearAria')}
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<Icon name="close" className="h-4 w-4" />
</button>
) : null}
</div>
<div className="flex flex-col gap-2 border-b border-border/40 px-3 py-2">
<div className="flex items-center justify-end gap-2">
{canCreateFile && (
<Tooltip>
<TooltipTrigger asChild>
@@ -1149,6 +1128,49 @@ export const SidebarFilesTree: React.FC = () => {
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>{t('sidebarFilesTree.actions.refreshTitle')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (root) collapseAllExpandedPaths(root);
}}
className="h-8 w-8 p-0 flex-shrink-0"
title={t('sidebarFilesTree.actions.collapseAllTitle')}
aria-label={t('sidebarFilesTree.actions.collapseAllTitle')}
>
<Icon name="collapse-vertical" className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>{t('sidebarFilesTree.actions.collapseAllTitle')}</TooltipContent>
</Tooltip>
</div>
<div className="relative min-w-0">
<Icon name="search" className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('sidebarFilesTree.search.placeholder')}
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 ? (
<button
type="button"
aria-label={t('sidebarFilesTree.search.clearAria')}
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<Icon name="close" className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-2">
@@ -85,9 +85,8 @@ export const CommandPalette: React.FC = () => {
const setSettingsPage = useUIStore((s) => s.setSettingsPage);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const openContextOverview = useUIStore((s) => s.openContextOverview);
const openContextSurface = useUIStore((s) => s.openContextSurface);
const openContextFile = useUIStore((s) => s.openContextFile);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
@@ -213,21 +212,15 @@ export const CommandPalette: React.FC = () => {
}
}),
},
{
id: 'toggle-right-sidebar',
title: t('commandPalette.item.toggleRightSidebar'),
icon: <Icon name="layout-right" className="mr-2 h-4 w-4" />,
shortcutId: 'toggle_right_sidebar',
searchText: t('commandPalette.item.toggleRightSidebar'),
onSelect: run(() => toggleRightSidebar()),
},
{
id: 'toggle-terminal',
title: t('commandPalette.item.toggleTerminal'),
icon: <Icon name="terminal-box" className="mr-2 h-4 w-4" />,
shortcutId: 'toggle_terminal',
searchText: t('commandPalette.item.toggleTerminal'),
onSelect: run(() => toggleBottomTerminal()),
onSelect: run(() => {
if (currentDirectory) openContextSurface(currentDirectory, 'terminal');
}),
},
{
id: 'context-usage',
@@ -273,8 +266,7 @@ export const CommandPalette: React.FC = () => {
setSessionSwitcherOpen,
openNewSessionDraft,
toggleSidebar,
toggleRightSidebar,
toggleBottomTerminal,
openContextSurface,
currentDirectory,
openContextOverview,
setSettingsDialogOpen,
@@ -151,12 +151,6 @@ export const HelpDialog: React.FC = () => {
icon: "layout-right",
keys: '',
},
{
id: 'cycle_right_sidebar_tab',
descriptionKey: 'helpDialog.item.cycleRightSidebarTab',
icon: "layout-right",
keys: '',
},
{
id: 'toggle_terminal',
descriptionKey: 'helpDialog.item.toggleTerminalDock',
@@ -536,9 +536,9 @@ const FileRow: React.FC<FileRowProps> = ({
>
{isDir ? (
isExpanded ? (
<Icon name="folder-open-fill" className="size-4 flex-shrink-0 text-primary/60" />
<Icon name="folder-open" className="size-4 flex-shrink-0 text-muted-foreground" />
) : (
<Icon name="folder-3-fill" className="size-4 flex-shrink-0 text-primary/60" />
<Icon name="folder-3" className="size-4 flex-shrink-0 text-muted-foreground" />
)
) : (
getFileIcon(node.path, node.extension)
+134 -172
View File
@@ -38,6 +38,7 @@ import {
CommandList,
} from '@/components/ui/command';
import { Icon } from "@/components/icon/Icon";
import { Button } from '@/components/ui/button';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore';
@@ -52,11 +53,12 @@ import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel';
import { CommitSection } from './git/CommitSection';
import { GitEmptyState } from './git/GitEmptyState';
import { HistorySection } from './git/HistorySection';
import { PullRequestSection } from './git/PullRequestSection';
import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog';
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { deriveBaseBranch } from './git/baseBranch';
import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
import type { GitRemote } from '@/lib/gitApi';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -68,7 +70,6 @@ import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
type BranchOperation = 'merge' | 'rebase' | null;
type ActionTab = 'commit' | 'branch' | 'pr';
type GitLogDialogMode = 'history' | 'graph';
type HistoryBranchDivider = {
insertBeforeIndex: number;
@@ -76,12 +77,8 @@ type HistoryBranchDivider = {
direction: 'up' | 'down';
} | null;
const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab';
const GIT_RECONCILE_DELAY_MS = 15000;
const isActionTab = (value: unknown): value is ActionTab =>
value === 'commit' || value === 'branch' || value === 'pr';
type GitViewSnapshot = {
directory?: string;
commitMessage: string;
@@ -302,8 +299,16 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
})));
const isMobile = useUIStore((state) => state.isMobile);
const openContextDiff = useUIStore((state) => state.openContextDiff);
const openContextSurface = useUIStore((state) => state.openContextSurface);
const prStatusBranch = status?.current ?? null;
const prChipStatus = useGitHubPrStatusStore((state) => {
if (!currentDirectory || !prStatusBranch) {
return null;
}
return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch);
});
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
@@ -617,21 +622,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
const actionTabItems = React.useMemo(() => [
{ id: 'commit', label: t('gitView.tabs.commit') },
{ id: 'branch', label: t('gitView.tabs.update') },
{ id: 'pr', label: t('gitView.tabs.pr') },
], [t]);
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
if (typeof window === 'undefined') {
return 'commit';
}
const stored = window.localStorage.getItem(GIT_ACTION_TAB_STORAGE_KEY);
if (stored === 'worktree') {
return 'branch';
}
return isActionTab(stored) ? stored : 'commit';
});
const [isUpdateBranchDialogOpen, setIsUpdateBranchDialogOpen] = React.useState(false);
const [isIntegrateCommitsDialogOpen, setIsIntegrateCommitsDialogOpen] = React.useState(false);
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [removingRemoteName, setRemovingRemoteName] = React.useState<string | null>(null);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
@@ -642,6 +634,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const [graphLog, setGraphLog] = React.useState<import('@/lib/api/types').GitLogResponse | null>(null);
const [graphLogLoading, setGraphLogLoading] = React.useState(false);
const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100);
const [graphLogRefreshToken, setGraphLogRefreshToken] = React.useState(0);
// Conflict state persistence key
const conflictStorageKey = React.useMemo(() => {
@@ -666,13 +659,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
window.localStorage.removeItem(conflictStorageKey);
}, [conflictStorageKey]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(GIT_ACTION_TAB_STORAGE_KEY, actionTab);
}, [actionTab]);
// Restore conflict state from localStorage on mount
React.useEffect(() => {
if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return;
@@ -1426,59 +1412,12 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const baseBranch = React.useMemo(() => {
const remoteNames = new Set(effectiveRemotes.map((remote) => remote.name));
const normalizeBaseCandidate = (value: string): string => {
if (!value) {
return '';
}
let normalized = value.trim();
if (!normalized || normalized === 'HEAD') {
return '';
}
if (localBranches.includes(normalized)) {
return normalized;
}
if (normalized.startsWith('refs/heads/')) {
normalized = normalized.slice('refs/heads/'.length);
}
if (normalized.startsWith('heads/')) {
normalized = normalized.slice('heads/'.length);
}
if (normalized.startsWith('remotes/')) {
normalized = normalized.slice('remotes/'.length);
}
const slashIndex = normalized.indexOf('/');
if (slashIndex > 0) {
const maybeRemote = normalized.slice(0, slashIndex);
if (remoteNames.has(maybeRemote)) {
const withoutRemote = normalized.slice(slashIndex + 1).trim();
if (withoutRemote) {
normalized = withoutRemote;
}
}
}
return normalized;
};
const fromMeta = normalizeBaseCandidate(
typeof worktreeMetadata?.createdFromBranch === 'string' ? worktreeMetadata.createdFromBranch : ''
);
if (fromMeta) return fromMeta;
const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : '');
if (fromHint) return fromHint;
if (localBranches.includes('main')) return 'main';
if (localBranches.includes('master')) return 'master';
if (localBranches.includes('develop')) return 'develop';
return 'main';
}, [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const updateTargetBranch = React.useMemo(() => {
const remoteNames = effectiveRemotes.map((remote) => remote.name);
@@ -1571,9 +1510,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const canShowIntegrateCommitsSection = Boolean(
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
const canShowPullRequestSection = Boolean(
currentDirectory && currentBranch
);
const canShowBranchWorkflows = Boolean(currentBranch);
const integrateCommitsProps =
canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata
@@ -1583,15 +1519,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
worktreeMetadata,
}
: null;
const pullRequestProps = React.useMemo(() => {
if (!canShowPullRequestSection || !currentDirectory || !currentBranch) {
return null;
}
return {
directory: currentDirectory,
branch: currentBranch,
};
}, [canShowPullRequestSection, currentBranch, currentDirectory]);
React.useEffect(() => {
if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) {
@@ -1681,7 +1608,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
if (!cancelled) setGraphLogLoading(false);
});
return () => { cancelled = true; };
}, [gitLogDialogMode, currentDirectory, graphLogMaxCount, git]);
}, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]);
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
@@ -1826,10 +1753,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return;
}
navigateToDiff(path, staged);
if (isMobile) {
setRightSidebarOpen(false);
}
}, [currentDirectory, isMobile, navigateToDiff, openContextDiff, setRightSidebarOpen]);
}, [currentDirectory, isMobile, navigateToDiff, openContextDiff]);
const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []);
@@ -2406,9 +2330,13 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
onOpenHistory={() => setGitLogDialogMode('history')}
onOpenGraph={() => setGitLogDialogMode('graph')}
onOpenStashes={openStashes}
actionTabItems={actionTabItems}
activeActionTab={actionTab}
onSelectActionTab={(tabID) => setActionTab(tabID as ActionTab)}
onOpenUpdateBranch={canShowBranchWorkflows ? () => setIsUpdateBranchDialogOpen(true) : undefined}
onOpenReintegrateCommits={integrateCommitsProps ? () => setIsIntegrateCommitsDialogOpen(true) : undefined}
pullRequest={prChipStatus?.pr ?? null}
prChecks={prChipStatus?.checks ?? null}
onOpenPullRequest={
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
}
/>
{/* In-progress operation banner */}
@@ -2438,8 +2366,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
disableHorizontal
preventOverscroll
>
{actionTab === 'commit' ? (
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex h-full min-h-0 flex-col gap-3">
{(changeEntries?.length ?? 0) > 0 ? (
<>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -2475,82 +2402,117 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
<GitEmptyState onOpenStashes={() => setIsStashesDialogOpen(true)} />
)}
</div>
) : null}
{actionTab === 'branch' ? (
<div className="space-y-4">
{canShowBranchWorkflows ? (
<>
<BranchIntegrationSection
mode="inline"
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
defaultTargetBranch={updateTargetBranch}
onMerge={handleMerge}
onRebase={handleRebase}
disabled={isBusy}
isOperating={branchOperation !== null}
operationLogs={operationLogs}
onOperationComplete={handleOperationComplete}
/>
{integrateCommitsProps ? (
<IntegrateCommitsSection
key={integrateCommitsProps.worktreeMetadata.path}
repoRoot={integrateCommitsProps.repoRoot}
sourceBranch={integrateCommitsProps.sourceBranch}
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : null}
</>
) : (
<p className="typography-meta text-muted-foreground">{t('gitView.branch.actionsUnavailable')}</p>
)}
</div>
) : null}
{actionTab === 'pr' ? (
<div className="space-y-4">
{pullRequestProps ? (
<PullRequestSection
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
onGeneratedDescription={scrollActionPanelToBottom}
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div>
<div className="typography-micro text-muted-foreground">
{t('gitView.pullRequest.createHint')}
</div>
</div>
)}
</div>
) : null}
</ScrollableOverlay>
</div>
</div>
</div>
<Dialog
open={isUpdateBranchDialogOpen}
onOpenChange={(open) => {
// Keep the dialog up while a merge/rebase is running so the
// operation log stays visible until it completes or fails.
if (!open && branchOperation !== null) {
return;
}
setIsUpdateBranchDialogOpen(open);
}}
>
<DialogContent className="max-w-2xl min-h-[26rem]">
<DialogHeader>
<DialogTitle>{t('gitView.branch.updateTitle')}</DialogTitle>
<DialogDescription>
{t('gitView.branch.updateDescriptionPrefix')}{' '}
<span className="font-mono text-foreground">{status?.current ?? ''}</span>.
</DialogDescription>
</DialogHeader>
{canShowBranchWorkflows ? (
<BranchIntegrationSection
mode="bare"
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
defaultTargetBranch={updateTargetBranch}
onMerge={handleMerge}
onRebase={handleRebase}
disabled={isBusy}
isOperating={branchOperation !== null}
operationLogs={operationLogs}
onOperationComplete={handleOperationComplete}
/>
) : (
<p className="typography-meta text-muted-foreground">{t('gitView.branch.actionsUnavailable')}</p>
)}
</DialogContent>
</Dialog>
<Dialog open={isIntegrateCommitsDialogOpen} onOpenChange={setIsIntegrateCommitsDialogOpen}>
<DialogContent className="max-w-2xl min-h-[26rem]">
<DialogHeader>
<DialogTitle>{t('gitView.integrate.title')}</DialogTitle>
<DialogDescription>
{integrateCommitsProps ? (
<span className="font-mono text-foreground">
{integrateCommitsProps.sourceBranch} {defaultTargetBranch}
</span>
) : null}
</DialogDescription>
</DialogHeader>
{integrateCommitsProps ? (
<IntegrateCommitsSection
key={integrateCommitsProps.worktreeMetadata.path}
repoRoot={integrateCommitsProps.repoRoot}
sourceBranch={integrateCommitsProps.sourceBranch}
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
showHeader={false}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : null}
</DialogContent>
</Dialog>
<Dialog open={gitLogDialogMode !== null} onOpenChange={(open) => { if (!open) setGitLogDialogMode(null); }}>
<DialogContent className="max-w-5xl h-[90vh] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')}
</DialogTitle>
<div className="flex items-center justify-between gap-2">
<DialogTitle>
{gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')}
</DialogTitle>
<Button
type="button"
variant="ghost"
size="sm"
className="mr-6 h-7 shrink-0 gap-1.5 px-2"
onClick={() => {
if (gitLogDialogMode === 'graph') {
setGraphLogRefreshToken((token) => token + 1);
return;
}
if (!currentDirectory) return;
void fetchLog(currentDirectory, git, logMaxCountLocal);
}}
disabled={gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading}
title={t('gitView.history.refresh')}
aria-label={t('gitView.history.refresh')}
>
<Icon
name="refresh"
className={cn(
'size-4',
(gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading) && 'animate-spin'
)}
/>
{t('gitView.history.refresh')}
</Button>
</div>
<DialogDescription>
{t('gitView.history.dialogDescription')}
</DialogDescription>
@@ -0,0 +1,253 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
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 { useShallow } from 'zustand/react/shallow';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type { GitRemote } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { PullRequestSection } from './git/PullRequestSection';
import { deriveBaseBranch } from './git/baseBranch';
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
// Remotes rarely change; remembering the last fetched list per directory lets
// a remount pick the same PR-status key immediately instead of flashing
// through the remote-less "checking status" state while remotes reload.
// Runtime-scoped so a backend switch never serves another runtime's remotes.
const remotesCacheByDirectory = new Map<string, GitRemote[]>();
const remoteUrlCacheByDirectory = new Map<string, string | null>();
const remoteCacheKey = (directory: string): string => `${getRuntimeKey()}::${directory}`;
/**
* Standalone pull-request surface: resolves the same repository context
* GitView does (branch, base branch, remotes) from the shared git stores and
* renders the pull-request workflow full-size in the context panel.
*/
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 })));
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees);
const normalizedCurrentDirectory = normalizePath(currentDirectory);
const inferredWorktreeMetadata = React.useMemo(() => {
if (!normalizedCurrentDirectory) {
return undefined;
}
const fromAvailable = availableWorktrees.find(
(metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory
);
if (fromAvailable) {
return fromAvailable;
}
for (const metadata of worktreeMap.values()) {
if (normalizePath(metadata.path) === normalizedCurrentDirectory) {
return metadata;
}
}
return undefined;
}, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]);
const storeWorktreeMetadata = React.useMemo(() => {
if (currentSessionId) {
return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata;
}
if (newSessionDraft?.open) {
return inferredWorktreeMetadata;
}
return undefined;
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
const worktreeAttachment = useSessionWorktreeStore((s) =>
currentSessionId ? s.getAttachment(currentSessionId) : undefined
);
const authoritativeProjectRoot = worktreeAttachment && !worktreeAttachment.degraded && !worktreeAttachment.legacy
? worktreeAttachment.worktreeRoot ?? undefined
: undefined;
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
React.useEffect(() => {
if (!currentDirectory || !git) {
return;
}
void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git]);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
React.useEffect(() => {
const projectRoot = authoritativeProjectRoot || worktreeMetadata?.projectDirectory;
if (!projectRoot) {
setRootBranchHint(null);
return;
}
let cancelled = false;
void getRootBranch(projectRoot)
.then((branch) => {
if (cancelled) return;
const normalized = branch.trim();
setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null);
})
.catch(() => {
if (!cancelled) {
setRootBranchHint(null);
}
});
return () => {
cancelled = true;
};
}, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]);
const [remotes, setRemotes] = React.useState<GitRemote[]>(() =>
(currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? []
);
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(() =>
(currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null
);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemotes) {
setRemotes([]);
return;
}
setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []);
let cancelled = false;
void git.getRemotes(currentDirectory)
.then((remoteList) => {
if (cancelled) return;
remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []);
setRemotes(remoteList ?? []);
})
.catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); });
return () => {
cancelled = true;
};
}, [currentDirectory, git]);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null);
return;
}
setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null);
let cancelled = false;
void git.getRemoteUrl(currentDirectory)
.then((url) => {
if (cancelled) return;
remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url);
setRemoteUrl(url);
})
.catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); });
return () => {
cancelled = true;
};
}, [currentDirectory, git]);
const localBranches = React.useMemo(() => {
if (!branches?.all) return [];
return branches.all
.filter((branchName: string) => !branchName.startsWith('remotes/'))
.sort();
}, [branches]);
const remoteBranches = React.useMemo(() => {
if (!branches?.all) return [];
return branches.all
.filter((branchName: string) => branchName.startsWith('remotes/'))
.map((branchName: string) => branchName.replace(/^remotes\//, ''))
.sort();
}, [branches]);
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
if (remotes.length > 0) {
return remotes;
}
const inferredNames = new Set<string>();
const tracking = status?.tracking?.trim();
if (tracking && tracking.includes('/')) {
inferredNames.add(tracking.split('/')[0]);
}
for (const branchName of remoteBranches) {
const slashIndex = branchName.indexOf('/');
if (slashIndex > 0) {
inferredNames.add(branchName.slice(0, slashIndex));
}
}
if (inferredNames.size === 0 && remoteUrl) {
inferredNames.add('origin');
}
return Array.from(inferredNames).map((name) => ({
name,
fetchUrl: remoteUrl ?? '',
pushUrl: remoteUrl ?? '',
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const currentBranch = status?.current ?? null;
if (!currentDirectory || !currentBranch) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('gitView.pullRequest.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('gitView.pullRequest.createHint')}</div>
</div>
);
}
return (
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<PullRequestSection
directory={currentDirectory}
branch={currentBranch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
/>
</ScrollableOverlay>
);
};
@@ -36,8 +36,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const terminalFontSize = useUIStore(state => state.terminalFontSize);
const terminalShell = useUIStore(state => state.terminalShell);
const terminalLoginShell = useUIStore(state => state.terminalLoginShells.includes(state.terminalShell));
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
const { isMobile, isTablet, hasTouchOnlyPointer } = useDeviceInfo();
const isTouchTerminal = isMobile || isTablet;
const useTouchTerminalInput = (isTouchTerminal || hasTouchOnlyPointer) && runtime.platform === 'web';
@@ -146,11 +144,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}, [useTouchTerminalInput]);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
const isTerminalActive = activeMainTab === 'terminal';
const isTerminalVisible = visible ?? (isTerminalActive || isBottomTerminalOpen);
const isTerminalVisible = visible ?? isTerminalActive;
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
React.useEffect(() => {
@@ -847,34 +842,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
};
}, [isTerminalVisible, terminalViewportKey, useTouchTerminalInput]);
React.useEffect(() => {
if (useTouchTerminalInput || !isTerminalVisible || !isBottomTerminalOpen) {
return;
}
const controller = terminalControllerRef.current;
if (!controller) {
return;
}
const fitOnce = () => {
controller.fit();
};
if (typeof window !== 'undefined') {
const rafId = window.requestAnimationFrame(() => {
fitOnce();
});
const timeoutIds = [320].map((delay) => window.setTimeout(fitOnce, delay));
return () => {
window.cancelAnimationFrame(rafId);
timeoutIds.forEach((id) => window.clearTimeout(id));
};
}
fitOnce();
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isTerminalVisible, useTouchTerminalInput]);
if (!hasActiveContext) {
return (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
@@ -899,7 +866,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
const shouldRenderViewport = hasOpenedTerminalViewport;
const showBottomDockControls = !isTouchTerminal && isBottomTerminalOpen && !isTerminalActive;
const quickKeySize: 'lg' | 'xs' = isTouchTerminal ? 'lg' : 'xs';
const quickKeyIconClass = isTouchTerminal ? 'w-10 p-0' : 'w-9 p-0';
const preserveTerminalFocus = (event: React.PointerEvent<HTMLButtonElement>) => {
@@ -1077,32 +1043,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
<span className="whitespace-nowrap">{t('terminalView.preview.open')}</span>
</Button>
) : null}
{showBottomDockControls ? (
<>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setBottomTerminalExpanded(!isBottomTerminalExpanded)}
className={cn('shrink-0 p-0', isMobile ? 'h-8 w-8' : 'h-7 w-7')}
title={isBottomTerminalExpanded ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
aria-label={isBottomTerminalExpanded ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
>
{isBottomTerminalExpanded ? <Icon name="fullscreen-exit" className="h-4 w-4" /> : <Icon name="fullscreen" className="h-4 w-4" />}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setBottomTerminalOpen(false)}
className={cn('shrink-0 p-0', isMobile ? 'h-8 w-8' : 'h-7 w-7')}
title={t('terminalView.bottomDock.closeTitle')}
aria-label={t('terminalView.bottomDock.closeAria')}
>
<Icon name="close" className="h-4 w-4" />
</Button>
</>
) : null}
</div>
</div>
) : null}
@@ -46,7 +46,12 @@ interface BranchIntegrationSectionProps {
isOperating?: boolean;
operationLogs?: OperationLogEntry[];
onOperationComplete?: () => void;
mode?: 'dialog' | 'inline';
/**
* 'dialog' renders its own trigger button + dialog, 'inline' renders a
* titled section, 'bare' renders just the form body for embedding in an
* externally-owned dialog.
*/
mode?: 'dialog' | 'inline' | 'bare';
defaultTargetBranch?: string;
}
@@ -167,7 +172,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
}, [branchDropdownOpen]);
React.useEffect(() => {
if (mode !== 'inline' || selectedBranch) return;
if (mode === 'dialog' || selectedBranch) return;
setSelectedBranch(resolveDefaultBranch());
}, [mode, resolveDefaultBranch, selectedBranch]);
@@ -412,6 +417,10 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
const body = isOperating ? renderOperating() : renderForm();
if (mode === 'bare') {
return body;
}
if (mode === 'inline') {
return (
<section className="border-0 bg-transparent rounded-none">
@@ -1,6 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,6 +17,8 @@ import type {
GitIdentityProfile,
GitRemote,
GitRemoteComparison,
GitHubPullRequest,
GitHubChecksSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
@@ -45,9 +46,11 @@ interface GitHeaderProps {
onOpenHistory?: () => void;
onOpenGraph?: () => void;
onOpenStashes?: () => void;
actionTabItems?: SortableTabsStripItem[];
activeActionTab?: string;
onSelectActionTab?: (tabID: string) => void;
onOpenUpdateBranch?: () => void;
onOpenReintegrateCommits?: () => void;
pullRequest?: GitHubPullRequest | null;
prChecks?: GitHubChecksSummary | null;
onOpenPullRequest?: () => void;
}
const IDENTITY_ICON_MAP: Record<string, IconName> = {
@@ -58,6 +61,7 @@ const IDENTITY_ICON_MAP: Record<string, IconName> = {
code: 'code',
heart: 'heart',
user: 'user-3',
fingerprint: 'fingerprint',
};
const IDENTITY_COLOR_MAP: Record<string, string> = {
@@ -249,9 +253,11 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onOpenHistory,
onOpenGraph,
onOpenStashes,
actionTabItems,
activeActionTab,
onSelectActionTab,
onOpenUpdateBranch,
onOpenReintegrateCommits,
pullRequest,
prChecks,
onOpenPullRequest,
}) => {
const { t } = useI18n();
if (!status) {
@@ -260,7 +266,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenHistory || onOpenGraph || onOpenStashes ? (
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -271,7 +277,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
className="h-8 w-8 px-0"
aria-label={t('gitView.header.repositoryViews')}
>
<Icon name="git-repository" className="size-4" />
<Icon name="more-fill" className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -286,7 +292,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
) : null}
{onOpenGraph ? (
<DropdownMenuItem onSelect={onOpenGraph}>
<Icon name="git-merge" className="size-4" />
<Icon name="git-branch" className="size-4" />
{t('gitView.graph.title')}
</DropdownMenuItem>
) : null}
@@ -296,12 +302,75 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
{t('gitView.stashes.title')}
</DropdownMenuItem>
) : null}
{onOpenUpdateBranch ? (
<DropdownMenuItem onSelect={onOpenUpdateBranch}>
<Icon name="git-merge" className="size-4" />
{t('gitView.header.updateBranch')}
</DropdownMenuItem>
) : null}
{onOpenReintegrateCommits ? (
<DropdownMenuItem onSelect={onOpenReintegrateCommits}>
<Icon name="split-cells-horizontal" className="size-4" />
{t('gitView.integrate.title')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
const prChecksColor = prChecks
? prChecks.state === 'success'
? 'var(--status-success)'
: prChecks.state === 'failure'
? 'var(--status-error)'
: 'var(--status-warning)'
: null;
const prVisualState = pullRequest
? pullRequest.state === 'merged'
? 'merged'
: pullRequest.state === 'closed'
? 'closed'
: pullRequest.draft
? 'draft'
: prChecks?.state === 'failure'
|| pullRequest.mergeable === false
|| pullRequest.mergeableState === 'blocked'
|| pullRequest.mergeableState === 'dirty'
? 'blocked'
: 'open'
: null;
const prChip = pullRequest && onOpenPullRequest ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onOpenPullRequest}
className="h-8 gap-1.5 px-2 typography-micro"
>
<Icon
name="git-pull-request"
className="size-3.5"
style={{ color: `var(--pr-${prVisualState})` }}
/>
<span className="tabular-nums text-foreground/80">{t('gitView.pr.numberLabel', { number: pullRequest.number })}</span>
{prChecksColor ? (
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: prChecksColor }}
/>
) : null}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.openPullRequest')}</TooltipContent>
</Tooltip>
) : null;
const syncButtons = (
<SyncActions
syncAction={syncAction}
@@ -360,30 +429,19 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{managementButtons}
{identityControl}
</div>
</div>
{actionTabItems && activeActionTab && onSelectActionTab ? (
<div className="mt-3 flex h-8 min-w-0 items-center gap-2">
<div className="min-w-0 flex-1">
<SortableTabsStrip
items={actionTabItems}
activeId={activeActionTab}
onSelect={onSelectActionTab}
layoutMode="fit"
variant="active-pill"
activePillButtonClassName="h-7"
className="h-full"
/>
</div>
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
) : null}
<div className="shrink-0">{syncButtons}</div>
</div>
) : null}
<div className="mt-3 flex h-8 min-w-0 items-center gap-2">
{prChip ? <div className="shrink-0">{prChip}</div> : null}
<div className="min-w-0 flex-1" />
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
) : null}
{managementButtons}
<div className="shrink-0">{syncButtons}</div>
</div>
</header>
);
};
@@ -50,6 +50,8 @@ export const IntegrateCommitsSection: React.FC<{
defaultTargetBranch: string;
refreshKey?: number;
onRefresh?: () => void;
/** Hide the built-in section heading when a dialog already provides one. */
showHeader?: boolean;
}> = ({
repoRoot,
sourceBranch,
@@ -58,6 +60,7 @@ export const IntegrateCommitsSection: React.FC<{
defaultTargetBranch,
refreshKey,
onRefresh,
showHeader = true,
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -332,22 +335,24 @@ export const IntegrateCommitsSection: React.FC<{
return (
<section className={containerClassName}>
<div className={headerClassName}>
<div className="flex items-center gap-2 min-w-0">
<Icon name="split-cells-horizontal" className="size-4 text-muted-foreground" />
<h3 className="typography-ui-header font-semibold text-foreground truncate">{t('gitView.integrate.title')}</h3>
{ui.kind === 'ready' && ui.plan.commits.length > 0 ? (
<span className="typography-meta text-muted-foreground truncate">
{t('gitView.integrate.toMoveCount', { count: ui.plan.commits.length })}
</span>
) : null}
{showHeader ? (
<div className={headerClassName}>
<div className="flex items-center gap-2 min-w-0">
<Icon name="split-cells-horizontal" className="size-4 text-muted-foreground" />
<h3 className="typography-ui-header font-semibold text-foreground truncate">{t('gitView.integrate.title')}</h3>
{ui.kind === 'ready' && ui.plan.commits.length > 0 ? (
<span className="typography-meta text-muted-foreground truncate">
{t('gitView.integrate.toMoveCount', { count: ui.plan.commits.length })}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
{ui.kind === 'loading' || ui.kind === 'running' ? (
<Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
<div className="flex items-center gap-2">
{ui.kind === 'loading' || ui.kind === 'running' ? (
<Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
<div className={bodyClassName}>
<div className="flex flex-wrap items-center gap-2">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
/**
* Derives the base ("target") branch a feature branch should compare and
* merge against. Shared by GitView and the standalone pull-request surface so
* both resolve the same base for the same repository state.
*/
export const deriveBaseBranch = (options: {
remoteNames: ReadonlySet<string>;
localBranches: readonly string[];
worktreeCreatedFromBranch?: string | null;
rootBranchHint?: string | null;
}): string => {
const { remoteNames, localBranches, worktreeCreatedFromBranch, rootBranchHint } = options;
const normalizeBaseCandidate = (value: string): string => {
if (!value) {
return '';
}
let normalized = value.trim();
if (!normalized || normalized === 'HEAD') {
return '';
}
if (localBranches.includes(normalized)) {
return normalized;
}
if (normalized.startsWith('refs/heads/')) {
normalized = normalized.slice('refs/heads/'.length);
}
if (normalized.startsWith('heads/')) {
normalized = normalized.slice('heads/'.length);
}
if (normalized.startsWith('remotes/')) {
normalized = normalized.slice('remotes/'.length);
}
const slashIndex = normalized.indexOf('/');
if (slashIndex > 0) {
const maybeRemote = normalized.slice(0, slashIndex);
if (remoteNames.has(maybeRemote)) {
const withoutRemote = normalized.slice(slashIndex + 1).trim();
if (withoutRemote) {
normalized = withoutRemote;
}
}
}
return normalized;
};
const fromMeta = normalizeBaseCandidate(
typeof worktreeCreatedFromBranch === 'string' ? worktreeCreatedFromBranch : ''
);
if (fromMeta) return fromMeta;
const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : '');
if (fromHint) return fromHint;
if (localBranches.includes('main')) return 'main';
if (localBranches.includes('master')) return 'master';
if (localBranches.includes('develop')) return 'develop';
return 'main';
};
+51 -38
View File
@@ -3,7 +3,7 @@ import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useUIStore } from '@/stores/useUIStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
@@ -25,11 +25,25 @@ export const useKeyboardShortcuts = () => {
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory);
// The terminal lives in the context panel; these mirror the rail behavior.
const toggleTerminalSurface = React.useCallback(() => {
if (!currentShortcutDirectory) return;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(currentShortcutDirectory), 'terminal');
}, [currentShortcutDirectory]);
const toggleTerminalSurfaceExpanded = React.useCallback(() => {
if (!currentShortcutDirectory) return;
const key = normalizeContextPanelDirectoryKey(currentShortcutDirectory);
const state = useUIStore.getState();
const panel = state.contextPanelByDirectory[key];
const activeMode = panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode : null;
if (activeMode !== 'terminal') {
state.openContextSurface(key, 'terminal');
}
state.toggleContextPanelExpanded(key);
}, [currentShortcutDirectory]);
const isMobile = useUIStore((s) => s.isMobile);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
@@ -97,18 +111,18 @@ export const useKeyboardShortcuts = () => {
}
e.preventDefault();
e.stopPropagation();
toggleBottomTerminal();
toggleTerminalSurface();
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) {
const { isMobile, isBottomTerminalExpanded } = useUIStore.getState();
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
e.stopPropagation();
setBottomTerminalExpanded(!isBottomTerminalExpanded);
toggleTerminalSurfaceExpanded();
return;
}
};
@@ -308,51 +322,53 @@ export const useKeyboardShortcuts = () => {
return;
}
// Legacy right-sidebar shortcuts now target the context surfaces that
// replaced the sidebar's tabs.
if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
const state = useUIStore.getState();
if (state.isMobile || !currentDirectory) {
return;
}
e.preventDefault();
toggleRightSidebar();
const directory = normalizeContextPanelDirectoryKey(currentDirectory);
const panelState = state.contextPanelByDirectory[directory];
if (panelState?.isOpen) {
state.closeContextPanel(directory);
} else if (panelState?.activeTabId) {
state.setActiveContextPanelTab(directory, panelState.activeTabId);
} else {
state.openContextSurface(directory, 'git');
}
return;
}
if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
const state = useUIStore.getState();
if (state.isMobile || !currentDirectory) {
return;
}
e.preventDefault();
setRightSidebarOpen(true);
setRightSidebarTab('git');
state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'git');
return;
}
if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
const state = useUIStore.getState();
if (state.isMobile || !currentDirectory) {
return;
}
e.preventDefault();
setRightSidebarOpen(true);
setRightSidebarTab('files');
state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'file');
return;
}
if (eventMatchesShortcut(e, combo('cycle_right_sidebar_tab'))) {
const { isMobile, rightSidebarTab } = useUIStore.getState();
if (isMobile) {
if (eventMatchesShortcut(e, combo('open_diff_panel'))) {
const state = useUIStore.getState();
if (state.isMobile || !currentDirectory) {
return;
}
const tabs = ['git', 'files', 'context'] as const;
const currentIndex = tabs.indexOf(rightSidebarTab);
const nextTab = tabs[(currentIndex + 1) % tabs.length];
e.preventDefault();
setRightSidebarOpen(true);
setRightSidebarTab(nextTab);
state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'diff');
return;
}
@@ -362,17 +378,17 @@ export const useKeyboardShortcuts = () => {
return;
}
e.preventDefault();
toggleBottomTerminal();
toggleTerminalSurface();
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) {
const { isMobile, isBottomTerminalExpanded } = useUIStore.getState();
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
setBottomTerminalExpanded(!isBottomTerminalExpanded);
toggleTerminalSurfaceExpanded();
return;
}
@@ -616,11 +632,8 @@ export const useKeyboardShortcuts = () => {
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
toggleRightSidebar,
setRightSidebarOpen,
setRightSidebarTab,
toggleBottomTerminal,
setBottomTerminalExpanded,
toggleTerminalSurface,
toggleTerminalSurfaceExpanded,
isMobile,
setSessionSwitcherOpen,
setActiveMainTab,
+44 -23
View File
@@ -4,7 +4,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncSessions } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -103,11 +103,6 @@ export const useMenuActions = (
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
const { setThemeMode } = useThemeSystem();
const checkUpdatesInFlightRef = React.useRef(false);
@@ -209,27 +204,58 @@ export const useMenuActions = (
handleChangeWorkspace();
break;
case 'toggle-right-sidebar':
toggleRightSidebar();
// Legacy right-sidebar menu items now target the context surfaces
// that replaced the sidebar's tabs.
case 'toggle-right-sidebar': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
const uiState = useUIStore.getState();
const directoryKey = normalizeContextPanelDirectoryKey(directory);
const panelState = uiState.contextPanelByDirectory[directoryKey];
if (panelState?.isOpen) {
uiState.closeContextPanel(directoryKey);
} else if (panelState?.activeTabId) {
uiState.setActiveContextPanelTab(directoryKey, panelState.activeTabId);
} else {
uiState.openContextSurface(directoryKey, 'git');
}
break;
}
case 'open-right-sidebar-git':
setRightSidebarOpen(true);
setRightSidebarTab('git');
case 'open-right-sidebar-git': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(directory), 'git');
break;
}
case 'open-right-sidebar-files':
setRightSidebarOpen(true);
setRightSidebarTab('files');
case 'open-right-sidebar-files': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(directory), 'file');
break;
}
case 'toggle-terminal':
toggleBottomTerminal();
case 'toggle-terminal': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(directory), 'terminal');
break;
}
case 'toggle-terminal-expanded':
setBottomTerminalExpanded(!useUIStore.getState().isBottomTerminalExpanded);
case 'toggle-terminal-expanded': {
const directory = useDirectoryStore.getState().currentDirectory;
if (!directory) break;
const key = normalizeContextPanelDirectoryKey(directory);
const uiState = useUIStore.getState();
const panel = uiState.contextPanelByDirectory[key];
const activeMode = panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode : null;
if (activeMode !== 'terminal') {
uiState.openContextSurface(key, 'terminal');
}
uiState.toggleContextPanelExpanded(key);
break;
}
case 'copy': {
const copyEvent = new Event('openchamber:copy', { cancelable: true });
@@ -307,14 +333,9 @@ export const useMenuActions = (
setSessionSwitcherOpen,
setCommandPaletteOpen,
setSettingsDialogOpen,
setBottomTerminalExpanded,
setRightSidebarOpen,
setRightSidebarTab,
setThemeMode,
toggleBottomTerminal,
toggleCommandPalette,
toggleHelpDialog,
toggleRightSidebar,
toggleSidebar,
]
);
+13 -1
View File
@@ -809,17 +809,24 @@ type GitHubRepoRef = {
url: string;
};
type GitHubChecksSummary = {
export type GitHubChecksSummary = {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
/** queued + in_progress + unconcluded runs. */
pending: number;
inProgress?: number;
queued?: number;
/** Earliest started_at among in-progress runs (ISO), for elapsed display. */
startedAt?: string;
};
export type GitHubCheckRun = {
id?: number;
name: string;
startedAt?: string;
completedAt?: string;
app?: {
name?: string;
slug?: string;
@@ -837,6 +844,7 @@ export type GitHubCheckRun = {
jobId?: number;
url?: string;
name?: string;
workflowName?: string;
conclusion?: string | null;
steps?: Array<{
name: string;
@@ -921,6 +929,8 @@ export type GitHubPullRequestsListResult = {
export type GitHubPullRequestContextResult = {
connected: boolean;
/** Server-side stamp of when the data was fetched from GitHub (ms epoch); survives server cache serves. */
fetchedAt?: number;
repo?: GitHubRepoRef | null;
pr?: GitHubPullRequestSummary | null;
issueComments?: GitHubIssueComment[];
@@ -933,6 +943,8 @@ export type GitHubPullRequestContextResult = {
export type GitHubPullRequestStatus = {
connected: boolean;
/** Server-side stamp of when the data was fetched from GitHub (ms epoch); survives server cache serves. */
fetchedAt?: number;
repo?: GitHubRepoRef | null;
branch?: string;
pr?: GitHubPullRequest | null;
+42
View File
@@ -0,0 +1,42 @@
import type { GitHubCheckRun, GitHubChecksSummary } from '@/lib/api/types';
/**
* Client-side mirror of the server's check-run aggregation so surfaces that
* hold the detailed run list (pulls/context) can derive the same summary the
* pr/status endpoint produces, instead of showing two differently-aged
* aggregates side by side.
*/
export const summarizeCheckRuns = (checkRuns: readonly GitHubCheckRun[]): GitHubChecksSummary => {
const counts = { success: 0, failure: 0, pending: 0, inProgress: 0, queued: 0 };
let startedAt: string | undefined;
for (const run of checkRuns) {
if (run.status === 'in_progress') {
counts.pending += 1;
counts.inProgress += 1;
if (run.startedAt && (!startedAt || run.startedAt < startedAt)) {
startedAt = run.startedAt;
}
continue;
}
if (run.status === 'queued') {
counts.pending += 1;
counts.queued += 1;
continue;
}
const conclusion = typeof run.conclusion === 'string' ? run.conclusion.toLowerCase() : '';
if (!conclusion) {
counts.pending += 1;
continue;
}
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
counts.success += 1;
} else {
counts.failure += 1;
}
}
const total = counts.success + counts.failure + counts.pending;
const state = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
return { state, total, ...counts, ...(startedAt ? { startedAt } : {}) };
};
@@ -1095,10 +1095,9 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Toggle terminal dock',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle right sidebar',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open right sidebar Git tab',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open right sidebar Files tab',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': 'Cycle right sidebar tab',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
+39 -22
View File
@@ -7,6 +7,10 @@ export const dict = {
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, lines {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Remove terminal context',
'chat.chatInput.prCommentContext': 'PR comments',
'chat.chatInput.prCommentContextRemove': 'Remove PR comments context',
'chat.chatInput.prCheckContext': 'Failed PR checks',
'chat.chatInput.prCheckContextRemove': 'Remove PR checks context',
'common.loading': 'Loading...',
'common.unavailable': 'Unavailable',
'common.language.english': 'English',
@@ -708,6 +712,8 @@ export const dict = {
'gitView.header.noIdentity': 'No identity',
'gitView.header.noProfiles': 'No profiles available to apply.',
'gitView.header.repositoryViews': 'Repository views',
'gitView.header.updateBranch': 'Update branch',
'gitView.header.openPullRequest': 'Open pull request',
'gitView.header.removeRemoteAria': 'Remove remote {name}',
'gitView.header.removeRemoteTitle': 'Remove remote {name}',
'gitView.header.upstreamSynced': 'synced',
@@ -759,6 +765,7 @@ export const dict = {
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
'gitView.history.renderDiffAnyway': 'Render anyway',
'gitView.history.title': 'History',
'gitView.history.refresh': 'Refresh',
'gitView.graph.title': 'Graph',
'gitView.integrate.checking': 'Checking…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick aborted',
@@ -893,15 +900,15 @@ export const dict = {
'gitView.pr.actions.openOnGitHubAria': 'Open pull request on GitHub',
'gitView.pr.actions.openSettings': 'Open settings',
'gitView.pr.actions.repo': 'Repository',
'gitView.pr.actions.resolveFailedChecks': 'Resolve failed checks',
'gitView.pr.actions.resolveFailedChecksAria': 'Send failed checks to agent',
'gitView.pr.actions.resolveFailedChecks': 'Attach failed checks',
'gitView.pr.actions.resolveFailedChecksAria': 'Attach failed checks to chat context',
'gitView.pr.actions.savePr': 'Save PR',
'gitView.pr.actions.savePrAria': 'Save pull request changes',
'gitView.pr.actions.sendCommentToAgent': 'Send comment to agent',
'gitView.pr.actions.sendCommentToAgentAria': 'Send this comment to agent',
'gitView.pr.actions.sendToAgent': 'Send to agent',
'gitView.pr.actions.shareComments': 'Share comments',
'gitView.pr.actions.shareCommentsAria': 'Send pull request comments to agent',
'gitView.pr.actions.sendCommentToAgent': 'Attach comment to chat context',
'gitView.pr.actions.sendCommentToAgentAria': 'Attach this comment to chat context',
'gitView.pr.actions.sendToAgent': 'Attach to chat',
'gitView.pr.actions.shareComments': 'Attach comments',
'gitView.pr.actions.shareCommentsAria': 'Attach pull request comments to chat context',
'gitView.pr.actions.toggleDraftAria': 'Toggle draft state',
'gitView.pr.actions.refresh': 'Refresh PR status',
'gitView.pr.actions.refreshAria': 'Refresh pull request status',
@@ -939,6 +946,10 @@ export const dict = {
'gitView.pr.noMergePermission': 'You do not have permission to merge this pull request.',
'gitView.pr.notMergeable': 'Not mergeable',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': 'Overview',
'gitView.pr.segment.checks': 'Checks',
'gitView.pr.segment.comments': 'Comments',
'gitView.pr.comments.addAll': 'Attach all to chat',
'gitView.pr.placeholder.additionalContext': 'Any constraints, rollout notes, or reviewer context...',
'gitView.pr.placeholder.description': 'Describe the change...',
'gitView.pr.placeholder.main': 'Summarize your pull request',
@@ -973,9 +984,6 @@ export const dict = {
'gitView.pullRequest.createHint': 'Create and manage pull requests from this branch.',
'gitView.pullRequest.availableOnFeatureBranches': 'Available when the current branch can open a pull request.',
'gitView.pullRequest.title': 'Pull request',
'gitView.tabs.commit': 'Commit',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': 'Update',
'gitView.tabs.worktree': 'Worktree',
'gitView.toast.abortOperationFailed': 'Failed to abort operation',
'gitView.toast.appliedIdentity': 'Applied identity: {name}',
@@ -1042,9 +1050,26 @@ export const dict = {
'contextPanel.mode.stagedDiff': 'Staged Diff',
'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.pr': 'Pull Request',
'contextPanel.mode.context': 'Context',
'contextPanel.mode.preview': 'Preview',
'contextPanel.mode.browser': 'Browser',
'contextRail.aria.rail': 'Panel surfaces',
'contextPanel.editorEmpty.title': 'No file open',
'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.',
'contextRail.surface.editor.description': 'Edit project files',
'contextRail.surface.git.description': 'Commits, branches, and pull requests',
'contextRail.surface.terminal.description': 'Built-in terminal',
'contextRail.surface.diff.description': 'Review working changes',
'contextRail.surface.plan.description': 'View the current plan',
'contextRail.surface.pr.description': 'Create, review, and merge the pull request for the current branch',
'contextRail.surface.notes.description': 'Notes, todos, and plans for the project',
'contextRail.surface.context.description': 'Session context and token usage',
'contextRail.surface.browser.description': 'Built-in web browser',
'contextRail.surface.preview.description': 'Dev server preview',
'contextRail.surface.chat.description': 'Session opened side by side',
'contextRail.surface.notes': 'Project notes',
'contextRail.editorTree.toggle': 'Toggle file tree',
'contextPanel.browser.open': 'Open browser panel',
'contextPanel.browser.addressAria': 'Browser address',
'contextPanel.browser.empty': 'Web browser',
@@ -1130,6 +1155,7 @@ export const dict = {
'sidebarFilesTree.actions.newFileTitle': 'New File',
'sidebarFilesTree.actions.newFolderTitle': 'New Folder',
'sidebarFilesTree.actions.refreshTitle': 'Refresh',
'sidebarFilesTree.actions.collapseAllTitle': 'Collapse all folders',
'sidebarFilesTree.actions.fileMenuTitle': 'File menu',
'sidebarFilesTree.state.searching': 'Searching...',
'sidebarFilesTree.state.loading': 'Loading...',
@@ -1492,13 +1518,6 @@ export const dict = {
'terminalView.quickKeys.enterAria': 'Enter',
'terminalView.tabs.closeTabTitle': 'Close tab',
'terminalView.tabs.newTabTitle': 'New tab',
'terminalView.bottomDock.resizeAria': 'Resize terminal panel',
'terminalView.bottomDock.restoreTitle': 'Restore terminal panel height',
'terminalView.bottomDock.restoreAria': 'Restore terminal panel height',
'terminalView.bottomDock.expandTitle': 'Expand terminal panel',
'terminalView.bottomDock.expandAria': 'Expand terminal panel',
'terminalView.bottomDock.closeTitle': 'Close terminal panel',
'terminalView.bottomDock.closeAria': 'Close terminal panel',
'terminalView.viewport.inputAria': 'Terminal input',
'directoryExplorerDialog.title': 'Add project directory',
'directoryExplorerDialog.description': 'Choose a folder to add as a project.',
@@ -1576,10 +1595,9 @@ export const dict = {
'helpDialog.item.focusChatInput': 'Focus Chat Input',
'helpDialog.item.togglePromptNavigator': 'Toggle Prompt Navigator',
'helpDialog.item.abortActiveRun': 'Abort active run (double press)',
'helpDialog.item.toggleRightSidebar': 'Toggle Right Sidebar',
'helpDialog.item.openRightSidebarGitTab': 'Open Right Sidebar Git Tab',
'helpDialog.item.openRightSidebarFilesTab': 'Open Right Sidebar Files Tab',
'helpDialog.item.cycleRightSidebarTab': 'Cycle Right Sidebar Tab',
'helpDialog.item.toggleRightSidebar': 'Toggle context panel',
'helpDialog.item.openRightSidebarGitTab': 'Open Git surface',
'helpDialog.item.openRightSidebarFilesTab': 'Open Files surface',
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
@@ -2196,7 +2214,6 @@ export const dict = {
'commandPalette.item.addProject': 'Add Project',
'commandPalette.item.showSessionSwitcher': 'Show Session Switcher',
'commandPalette.item.toggleSidebar': 'Toggle Sidebar',
'commandPalette.item.toggleRightSidebar': 'Toggle Right Sidebar',
'commandPalette.item.showContextUsage': 'Show Context Usage',
'commandPalette.item.toggleTerminal': 'Toggle Terminal',
'commandPalette.item.openSettings': 'Open Settings...',
@@ -1062,10 +1062,9 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar u ocultar panel de terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": "Mostrar u ocultar barra lateral derecha",
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": "Abrir pestaña de Git en barra lateral derecha",
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": "Abrir pestaña de Archivos en barra lateral derecha",
"settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label": "Cambiar pestaña de la barra lateral derecha",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, líneas {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Eliminar contexto del terminal',
'chat.chatInput.prCommentContext': 'Comentarios del PR',
'chat.chatInput.prCommentContextRemove': 'Quitar el contexto de comentarios del PR',
'chat.chatInput.prCheckContext': 'Comprobaciones del PR fallidas',
'chat.chatInput.prCheckContextRemove': 'Quitar el contexto de comprobaciones del PR',
"common.loading": "Cargando...",
"common.unavailable": "No disponible",
"common.language.english": "Inglés",
@@ -709,6 +713,8 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.noIdentity": "Sin identidad",
"gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.",
"gitView.header.repositoryViews": "Vistas del repositorio",
"gitView.header.updateBranch": "Actualizar rama",
"gitView.header.openPullRequest": "Abrir pull request",
"gitView.header.removeRemoteAria": "Eliminar remoto",
"gitView.header.removeRemoteTitle": "Eliminar remoto",
"gitView.header.upstreamSynced": "sincronizado",
@@ -760,6 +766,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado",
"gitView.history.renderDiffAnyway": "Renderizar igualmente",
"gitView.history.title": "Historial",
"gitView.history.refresh": "Actualizar",
"gitView.graph.title": "Grafo",
"gitView.integrate.checking": "Verificando…",
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
@@ -894,15 +901,15 @@ export const dict: Record<I18nKey, string> = {
"gitView.pr.actions.openOnGitHubAria": "Abrir PR en GitHub",
"gitView.pr.actions.openSettings": "Abrir configuración",
"gitView.pr.actions.repo": "Repositorio",
"gitView.pr.actions.resolveFailedChecks": "Resolver checks fallidos",
"gitView.pr.actions.resolveFailedChecksAria": "Enviar checks fallidos al agente",
"gitView.pr.actions.resolveFailedChecks": "Adjuntar comprobaciones fallidas",
"gitView.pr.actions.resolveFailedChecksAria": "Adjuntar comprobaciones fallidas al contexto del chat",
"gitView.pr.actions.savePr": "Guardar PR",
"gitView.pr.actions.savePrAria": "Guardar cambios de la PR",
"gitView.pr.actions.sendCommentToAgent": "Enviar comentario al agente",
"gitView.pr.actions.sendCommentToAgentAria": "Enviar este comentario al agente",
"gitView.pr.actions.sendToAgent": "Enviar al agente",
"gitView.pr.actions.shareComments": "Compartir comentarios",
"gitView.pr.actions.shareCommentsAria": "Enviar comentarios de la PR al agente",
"gitView.pr.actions.sendCommentToAgent": "Adjuntar comentario al contexto del chat",
"gitView.pr.actions.sendCommentToAgentAria": "Adjuntar este comentario al contexto del chat",
"gitView.pr.actions.sendToAgent": "Adjuntar al chat",
"gitView.pr.actions.shareComments": "Adjuntar comentarios",
"gitView.pr.actions.shareCommentsAria": "Adjuntar los comentarios del pull request al contexto del chat",
"gitView.pr.actions.toggleDraftAria": "Alternar estado de borrador",
"gitView.pr.actions.refresh": "Actualizar estado de la PR",
"gitView.pr.actions.refreshAria": "Actualizar estado del pull request",
@@ -940,6 +947,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.pr.noMergePermission": "No tienes permiso para hacer merge de esta PR.",
"gitView.pr.notMergeable": "No se puede hacer merge",
"gitView.pr.numberLabel": "PR # {number}",
"gitView.pr.segment.overview": "Resumen",
"gitView.pr.segment.checks": "Comprobaciones",
"gitView.pr.segment.comments": "Comentarios",
"gitView.pr.comments.addAll": "Adjuntar todo al chat",
"gitView.pr.placeholder.additionalContext": "Cualquier restricción, nota de lanzamiento o contexto para revisor...",
"gitView.pr.placeholder.description": "Describe el cambio...",
"gitView.pr.placeholder.main": "Resume tu PR",
@@ -974,9 +985,6 @@ export const dict: Record<I18nKey, string> = {
"gitView.pullRequest.createHint": "Crea y gestiona PR desde esta rama.",
"gitView.pullRequest.availableOnFeatureBranches": "Disponible cuando la rama actual puede abrir un PR.",
"gitView.pullRequest.title": "PR",
"gitView.tabs.commit": "Commit",
"gitView.tabs.pr": "PR",
"gitView.tabs.update": "Actualizar",
"gitView.tabs.worktree": "Árbol de trabajo",
"gitView.toast.abortOperationFailed": "No se pudo abortar la operación",
"gitView.toast.appliedIdentity": "Identidad aplicada: {name}",
@@ -1043,9 +1051,26 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.mode.stagedDiff": "Staged Diff",
"contextPanel.mode.workingDiff": "Working Diff",
"contextPanel.mode.plan": "Plan",
"contextPanel.mode.pr": "Pull Request",
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Vista previa",
"contextPanel.mode.browser": "Navegador",
"contextRail.aria.rail": "Superficies del panel",
"contextPanel.editorEmpty.title": "Ningún archivo abierto",
"contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.",
"contextRail.surface.editor.description": "Editar archivos del proyecto",
"contextRail.surface.git.description": "Commits, ramas y pull requests",
"contextRail.surface.terminal.description": "Terminal integrada",
"contextRail.surface.diff.description": "Revisar cambios en curso",
"contextRail.surface.plan.description": "Ver el plan actual",
"contextRail.surface.pr.description": "Crea, revisa y fusiona el pull request de la rama actual",
"contextRail.surface.notes.description": "Notas, tareas y planes del proyecto",
"contextRail.surface.context.description": "Contexto de la sesión y uso de tokens",
"contextRail.surface.browser.description": "Navegador web integrado",
"contextRail.surface.preview.description": "Vista previa del servidor de desarrollo",
"contextRail.surface.chat.description": "Sesión abierta en paralelo",
"contextRail.surface.notes": "Notas del proyecto",
"contextRail.editorTree.toggle": "Alternar árbol de archivos",
"contextPanel.browser.open": "Abrir panel del navegador",
"contextPanel.browser.addressAria": "Dirección del navegador",
"contextPanel.browser.empty": "Navegador web",
@@ -1096,6 +1121,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.actions.newFileTitle": "Nuevo archivo",
"sidebarFilesTree.actions.newFolderTitle": "Nueva carpeta",
"sidebarFilesTree.actions.refreshTitle": "Actualizar",
"sidebarFilesTree.actions.collapseAllTitle": "Contraer todas las carpetas",
"sidebarFilesTree.actions.fileMenuTitle": "Menú de archivo",
"sidebarFilesTree.state.searching": "Buscando...",
"sidebarFilesTree.state.loading": "Cargando...",
@@ -1470,13 +1496,6 @@ export const dict: Record<I18nKey, string> = {
"terminalView.quickKeys.enterAria": "Enter",
"terminalView.tabs.closeTabTitle": "Cerrar pestaña",
"terminalView.tabs.newTabTitle": "Nueva pestaña",
"terminalView.bottomDock.resizeAria": "Ajustar tamaño del panel de terminal",
"terminalView.bottomDock.restoreTitle": "Restaurar altura del panel de terminal",
"terminalView.bottomDock.restoreAria": "Restaurar altura del panel de terminal",
"terminalView.bottomDock.expandTitle": "Expandir panel de terminal",
"terminalView.bottomDock.expandAria": "Expandir panel de terminal",
"terminalView.bottomDock.closeTitle": "Cerrar panel de terminal",
"terminalView.bottomDock.closeAria": "Cerrar panel de terminal",
"terminalView.viewport.inputAria": "Entrada de terminal",
"directoryExplorerDialog.title": "Añadir directorio de proyecto",
"directoryExplorerDialog.description": "Elige una carpeta para añadir como proyecto.",
@@ -1554,10 +1573,9 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.focusChatInput": "Enfocar entrada de chat",
"helpDialog.item.togglePromptNavigator": "Mostrar u ocultar navegador de prompts",
"helpDialog.item.abortActiveRun": "Detener ejecución activa (doble presionar)",
"helpDialog.item.toggleRightSidebar": "Mostrar u ocultar barra lateral derecha",
"helpDialog.item.openRightSidebarGitTab": "Abrir pestaña de Git en barra lateral derecha",
"helpDialog.item.openRightSidebarFilesTab": "Abrir pestaña de archivos en barra lateral derecha",
"helpDialog.item.cycleRightSidebarTab": "Cambiar pestaña de la barra lateral derecha",
"helpDialog.item.toggleRightSidebar": 'Alternar panel de contexto',
"helpDialog.item.openRightSidebarGitTab": 'Abrir superficie de Git',
"helpDialog.item.openRightSidebarFilesTab": 'Abrir superficie de archivos',
"helpDialog.item.toggleTerminalDock": "Mostrar u ocultar dock de terminal",
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
@@ -2162,7 +2180,6 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.addProject": "Añadir proyecto",
"commandPalette.item.showSessionSwitcher": "Mostrar cambiador de sesiones",
"commandPalette.item.toggleSidebar": "Mostrar u ocultar barra lateral",
"commandPalette.item.toggleRightSidebar": "Mostrar u ocultar barra lateral derecha",
"commandPalette.item.showContextUsage": "Mostrar uso del contexto",
"commandPalette.item.toggleTerminal": "Mostrar u ocultar terminal",
"commandPalette.item.openSettings": "Abrir configuración...",
@@ -983,10 +983,9 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Basculer la station d\'accueil du terminal',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Basculer la barre latérale droite',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir l\'onglet Git de la barre latérale droite',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir l\'onglet Fichiers de la barre latérale droite',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': 'Faire défiler l\'onglet de la barre latérale droite',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
+39 -22
View File
@@ -7,6 +7,10 @@ export const dict = {
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, lignes {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Supprimer le contexte du terminal',
'chat.chatInput.prCommentContext': 'Commentaires de PR',
'chat.chatInput.prCommentContextRemove': 'Retirer le contexte des commentaires de PR',
'chat.chatInput.prCheckContext': 'Vérifications de PR échouées',
'chat.chatInput.prCheckContextRemove': 'Retirer le contexte des vérifications de PR',
'common.loading': 'Chargement...',
'common.unavailable': 'Indisponible',
'common.language.english': 'Anglais',
@@ -543,6 +547,8 @@ export const dict = {
'gitView.header.noIdentity': 'Aucune identité',
'gitView.header.noProfiles': 'Aucun profil disponible à appliquer.',
'gitView.header.repositoryViews': 'Vues du dépôt',
'gitView.header.updateBranch': 'Mettre à jour la branche',
'gitView.header.openPullRequest': 'Ouvrir la pull request',
'gitView.header.removeRemoteAria': 'Supprimer le remote',
'gitView.header.removeRemoteTitle': 'Supprimer le remote',
'gitView.header.upstreamSynced': 'synchronisé',
@@ -593,6 +599,7 @@ export const dict = {
'gitView.history.renamedNoDiff': 'Fichier renommé  diff non pris en charge',
'gitView.history.renderDiffAnyway': 'Rendre quand même',
'gitView.history.title': 'Historique',
'gitView.history.refresh': 'Actualiser',
'gitView.graph.title': 'Graphique',
'gitView.integrate.checking': 'Vérification…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick annulé',
@@ -724,15 +731,15 @@ export const dict = {
'gitView.pr.actions.openOnGitHubAria': 'Ouvrir la PR sur GitHub',
'gitView.pr.actions.openSettings': 'Ouvrir les paramètres',
'gitView.pr.actions.repo': 'Dépôt',
'gitView.pr.actions.resolveFailedChecks': 'Résoudre les contrôles échoués',
'gitView.pr.actions.resolveFailedChecksAria': 'Envoyer les chèques échoués à l\'agent',
'gitView.pr.actions.resolveFailedChecks': 'Joindre les vérifications échouées',
'gitView.pr.actions.resolveFailedChecksAria': 'Joindre les vérifications échouées au contexte du chat',
'gitView.pr.actions.savePr': 'Enregistrer la PR',
'gitView.pr.actions.savePrAria': 'Enregistrer les modifications de la PR',
'gitView.pr.actions.sendCommentToAgent': 'Envoyer le commentaire à l\'agent',
'gitView.pr.actions.sendCommentToAgentAria': 'Envoyer ce commentaire à l\'agent',
'gitView.pr.actions.sendToAgent': 'Envoyer à l\'agent',
'gitView.pr.actions.shareComments': 'Partager les commentaires',
'gitView.pr.actions.shareCommentsAria': 'Envoyer les commentaires de la PR à l\'agent',
'gitView.pr.actions.sendCommentToAgent': 'Joindre le commentaire au contexte du chat',
'gitView.pr.actions.sendCommentToAgentAria': 'Joindre ce commentaire au contexte du chat',
'gitView.pr.actions.sendToAgent': 'Joindre au chat',
'gitView.pr.actions.shareComments': 'Joindre les commentaires',
'gitView.pr.actions.shareCommentsAria': 'Joindre les commentaires de la pull request au contexte du chat',
'gitView.pr.actions.toggleDraftAria': 'Basculer l\'état de brouillon',
'gitView.pr.actions.refresh': 'Actualiser le statut PR',
'gitView.pr.actions.refreshAria': 'Actualiser l\'état de la demande d\'extraction',
@@ -770,6 +777,10 @@ export const dict = {
'gitView.pr.noMergePermission': 'Vous n\'êtes pas autorisé à fusionner cette demande d\'extraction.',
'gitView.pr.notMergeable': 'Non fusionnable',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': 'Aperçu',
'gitView.pr.segment.checks': 'Vérifications',
'gitView.pr.segment.comments': 'Commentaires',
'gitView.pr.comments.addAll': 'Tout joindre au chat',
'gitView.pr.placeholder.additionalContext': 'Toute contrainte, note de déploiement ou contexte du réviseur...',
'gitView.pr.placeholder.description': 'Décrivez le changement...',
'gitView.pr.placeholder.main': 'Résumez votre PR',
@@ -804,9 +815,6 @@ export const dict = {
'gitView.pullRequest.createHint': 'Créez et gérez les PR depuis cette branche.',
'gitView.pullRequest.availableOnFeatureBranches': 'Disponible lorsque la branche actuelle peut ouvrir une PR.',
'gitView.pullRequest.title': 'PR',
'gitView.tabs.commit': 'Commettre',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': 'Mise à jour',
'gitView.tabs.worktree': 'Worktree',
'gitView.toast.abortOperationFailed': 'Échec de l\'annulation de l\'opération',
'gitView.toast.appliedIdentity': 'Identité appliquée : {name}',
@@ -873,9 +881,26 @@ export const dict = {
'contextPanel.mode.stagedDiff': 'Différence par étapes',
'contextPanel.mode.workingDiff': 'Différentiel de travail',
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.pr': 'Pull Request',
'contextPanel.mode.context': 'Contexte',
'contextPanel.mode.preview': 'Aperçu',
'contextPanel.mode.browser': 'Navigateur',
'contextRail.aria.rail': 'Surfaces du panneau',
'contextPanel.editorEmpty.title': 'Aucun fichier ouvert',
'contextPanel.editorEmpty.description': 'Choisissez un fichier dans larborescence pour commencer.',
'contextRail.surface.editor.description': 'Modifier les fichiers du projet',
'contextRail.surface.git.description': 'Commits, branches et pull requests',
'contextRail.surface.terminal.description': 'Terminal intégré',
'contextRail.surface.diff.description': 'Passer en revue les modifications',
'contextRail.surface.plan.description': 'Voir le plan actuel',
'contextRail.surface.pr.description': 'Créer, relire et fusionner la pull request de la branche actuelle',
'contextRail.surface.notes.description': 'Notes, tâches et plans du projet',
'contextRail.surface.context.description': 'Contexte de session et utilisation des tokens',
'contextRail.surface.browser.description': 'Navigateur web intégré',
'contextRail.surface.preview.description': 'Aperçu du serveur de développement',
'contextRail.surface.chat.description': 'Session ouverte côte à côte',
'contextRail.surface.notes': 'Notes du projet',
'contextRail.editorTree.toggle': 'Afficher/masquer larborescence de fichiers',
'contextPanel.browser.open': 'Ouvrir le panneau du navigateur',
'contextPanel.browser.addressAria': 'Adresse du navigateur',
'contextPanel.browser.empty': 'Navigateur Internet',
@@ -958,6 +983,7 @@ export const dict = {
'sidebarFilesTree.actions.newFileTitle': 'Nouveau fichier',
'sidebarFilesTree.actions.newFolderTitle': 'Nouveau dossier',
'sidebarFilesTree.actions.refreshTitle': 'Rafraîchir',
'sidebarFilesTree.actions.collapseAllTitle': 'Réduire tous les dossiers',
'sidebarFilesTree.actions.fileMenuTitle': 'Menu Fichier',
'sidebarFilesTree.state.searching': 'Recherche...',
'sidebarFilesTree.state.loading': 'Chargement...',
@@ -1311,13 +1337,6 @@ export const dict = {
'terminalView.quickKeys.enterAria': 'Entrer',
'terminalView.tabs.closeTabTitle': 'Fermer l\'onglet',
'terminalView.tabs.newTabTitle': 'Nouvel onglet',
'terminalView.bottomDock.resizeAria': 'Redimensionner le panneau à bornes',
'terminalView.bottomDock.restoreTitle': 'Restaurer la hauteur du bornier',
'terminalView.bottomDock.restoreAria': 'Restaurer la hauteur du bornier',
'terminalView.bottomDock.expandTitle': 'Développer le panneau de connexion',
'terminalView.bottomDock.expandAria': 'Développer le panneau de connexion',
'terminalView.bottomDock.closeTitle': 'Fermer le panneau à bornes',
'terminalView.bottomDock.closeAria': 'Fermer le panneau à bornes',
'terminalView.viewport.inputAria': 'Entrée de borne',
'directoryExplorerDialog.title': 'Ajouter un répertoire de projet',
'directoryExplorerDialog.description': 'Choisissez un dossier à ajouter en tant que projet.',
@@ -1395,10 +1414,9 @@ export const dict = {
'helpDialog.item.focusChatInput': 'Concentration sur la saisie du chat',
'helpDialog.item.togglePromptNavigator': 'Afficher ou masquer le navigateur de prompts',
'helpDialog.item.abortActiveRun': 'Abandonner lexécution active (double pression)',
'helpDialog.item.toggleRightSidebar': 'Basculer la barre latérale droite',
'helpDialog.item.openRightSidebarGitTab': 'Ouvrir l\'onglet Git de la barre latérale droite',
'helpDialog.item.openRightSidebarFilesTab': 'Ouvrir l\'onglet Fichiers de la barre latérale droite',
'helpDialog.item.cycleRightSidebarTab': 'Onglet de la barre latérale droite du cycle',
'helpDialog.item.toggleRightSidebar': 'Afficher/masquer le panneau de contexte',
'helpDialog.item.openRightSidebarGitTab': 'Ouvrir la surface Git',
'helpDialog.item.openRightSidebarFilesTab': 'Ouvrir la surface Fichiers',
'helpDialog.item.toggleTerminalDock': 'Basculer la station d\'accueil du terminal',
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
@@ -1962,7 +1980,6 @@ export const dict = {
'commandPalette.item.addProject': 'Ajouter un projet',
'commandPalette.item.showSessionSwitcher': 'Afficher le sélecteur de session',
'commandPalette.item.toggleSidebar': 'Basculer la barre latérale',
'commandPalette.item.toggleRightSidebar': 'Basculer la barre latérale droite',
'commandPalette.item.showContextUsage': 'Afficher l\'utilisation du contexte',
'commandPalette.item.toggleTerminal': 'Basculer le terminal',
'commandPalette.item.openSettings': 'Ouvrez les paramètres...',
@@ -1095,10 +1095,9 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '右サイドバーの切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '右サイドバー Git タブを開く',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '右サイドバー ファイルタブを開く',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '右サイドバータブを順に切替',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
'chat.chatInput.terminalContext': '{terminal}、{start}〜{end}行',
'chat.chatInput.terminalContextRemove': 'ターミナルコンテキストを削除',
'chat.chatInput.prCommentContext': 'PRコメント',
'chat.chatInput.prCommentContextRemove': 'PRコメントのコンテキストを削除',
'chat.chatInput.prCheckContext': '失敗したPRチェック',
'chat.chatInput.prCheckContextRemove': 'PRチェックのコンテキストを削除',
'common.loading': '読み込み中...',
'common.unavailable': '利用できません',
'common.language.english': '英語',
@@ -706,6 +710,8 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noIdentity': 'IDがありません',
'gitView.header.noProfiles': '適用できるプロファイルがありません。',
'gitView.header.repositoryViews': 'リポジトリビュー',
'gitView.header.updateBranch': 'ブランチを更新',
'gitView.header.openPullRequest': 'プルリクエストを開く',
'gitView.header.removeRemoteAria': 'リモート{name}を削除',
'gitView.header.removeRemoteTitle': 'リモート{name}を削除',
'gitView.header.upstreamSynced': '同期済み',
@@ -756,6 +762,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.history.renamedNoDiff': '名前変更されたファイル — 差分はサポートされていません',
'gitView.history.renderDiffAnyway': 'とにかくレンダリング',
'gitView.history.title': '履歴',
'gitView.history.refresh': '更新',
'gitView.graph.title': 'グラフ',
'gitView.integrate.checking': '確認中…',
'gitView.integrate.cherryPickAbortedToast': 'チェリーピックを中止しました',
@@ -890,15 +897,15 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.actions.openOnGitHubAria': 'プルリクエストをGitHubで開く',
'gitView.pr.actions.openSettings': '設定を開く',
'gitView.pr.actions.repo': 'リポジトリ',
'gitView.pr.actions.resolveFailedChecks': '失敗したチェックを解決',
'gitView.pr.actions.resolveFailedChecksAria': '失敗したチェックをエージェントに送信',
'gitView.pr.actions.resolveFailedChecks': '失敗したチェックを添付',
'gitView.pr.actions.resolveFailedChecksAria': '失敗したチェックをチャットのコンテキストに添付',
'gitView.pr.actions.savePr': 'PRを保存',
'gitView.pr.actions.savePrAria': 'プルリクエストの変更を保存',
'gitView.pr.actions.sendCommentToAgent': 'コメントをエージェントに送信',
'gitView.pr.actions.sendCommentToAgentAria': 'このコメントをエージェントに送信',
'gitView.pr.actions.sendToAgent': 'エージェントに送信',
'gitView.pr.actions.shareComments': 'コメントを共有',
'gitView.pr.actions.shareCommentsAria': 'プルリクエストのコメントをエージェントに送信',
'gitView.pr.actions.sendCommentToAgent': 'コメントをチャットのコンテキストに添付',
'gitView.pr.actions.sendCommentToAgentAria': 'このコメントをチャットのコンテキストに添付',
'gitView.pr.actions.sendToAgent': 'チャットに添付',
'gitView.pr.actions.shareComments': 'コメントを添付',
'gitView.pr.actions.shareCommentsAria': 'プルリクエストのコメントをチャットのコンテキストに添付',
'gitView.pr.actions.toggleDraftAria': '下書き状態を切り替え',
'gitView.pr.actions.refresh': 'PRステータスを更新',
'gitView.pr.actions.refreshAria': 'プルリクエストのステータスを更新',
@@ -936,6 +943,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.noMergePermission': 'このプルリクエストをマージする権限がありません。',
'gitView.pr.notMergeable': 'マージ不可',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': '概要',
'gitView.pr.segment.checks': 'チェック',
'gitView.pr.segment.comments': 'コメント',
'gitView.pr.comments.addAll': 'すべてチャットに添付',
'gitView.pr.placeholder.additionalContext': '制約、ロールアウトノート、レビューアーコンテキスト...',
'gitView.pr.placeholder.description': '変更内容を説明...',
'gitView.pr.placeholder.main': 'プルリクエストを要約',
@@ -970,9 +981,6 @@ export const dict: Record<I18nKey, string> = {
'gitView.pullRequest.createHint': 'このブランチからプルリクエストを作成・管理します。',
'gitView.pullRequest.availableOnFeatureBranches': '現在のブランチがプルリクエストを開ける場合に利用可能です。',
'gitView.pullRequest.title': 'プルリクエスト',
'gitView.tabs.commit': 'コミット',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': '更新',
'gitView.tabs.worktree': 'ワークツリー',
'gitView.toast.abortOperationFailed': '操作の中止に失敗しました',
'gitView.toast.appliedIdentity': 'IDを適用しました: {name}',
@@ -1039,9 +1047,26 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.stagedDiff': 'ステージ済み差分',
'contextPanel.mode.workingDiff': '作業中差分',
'contextPanel.mode.plan': '計画',
'contextPanel.mode.pr': 'プルリクエスト',
'contextPanel.mode.context': 'コンテキスト',
'contextPanel.mode.preview': 'プレビュー',
'contextPanel.mode.browser': 'ブラウザ',
'contextRail.aria.rail': 'パネルサーフェス',
'contextPanel.editorEmpty.title': 'ファイルが開かれていません',
'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。',
'contextRail.surface.editor.description': 'プロジェクトのファイルを編集',
'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト',
'contextRail.surface.terminal.description': '内蔵ターミナル',
'contextRail.surface.diff.description': '作業中の変更をレビュー',
'contextRail.surface.plan.description': '現在のプランを表示',
'contextRail.surface.pr.description': '現在のブランチのプルリクエストを作成・確認・マージ',
'contextRail.surface.notes.description': 'プロジェクトのノート・ToDo・プラン',
'contextRail.surface.context.description': 'セッションのコンテキストとトークン使用量',
'contextRail.surface.browser.description': '内蔵ウェブブラウザ',
'contextRail.surface.preview.description': '開発サーバーのプレビュー',
'contextRail.surface.chat.description': '並べて開いたセッション',
'contextRail.surface.notes': 'プロジェクトノート',
'contextRail.editorTree.toggle': 'ファイルツリーの表示切替',
'contextPanel.browser.open': 'ブラウザパネルを開く',
'contextPanel.browser.addressAria': 'ブラウザアドレス',
'contextPanel.browser.empty': 'ウェブブラウザ',
@@ -1126,6 +1151,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.actions.newFileTitle': '新しいファイル',
'sidebarFilesTree.actions.newFolderTitle': '新しいフォルダ',
'sidebarFilesTree.actions.refreshTitle': '更新',
'sidebarFilesTree.actions.collapseAllTitle': 'すべてのフォルダを折りたたむ',
'sidebarFilesTree.actions.fileMenuTitle': 'ファイルメニュー',
'sidebarFilesTree.state.searching': '検索中...',
'sidebarFilesTree.state.loading': '読み込み中...',
@@ -1488,13 +1514,6 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.enterAria': 'Enter',
'terminalView.tabs.closeTabTitle': 'タブを閉じる',
'terminalView.tabs.newTabTitle': '新しいタブ',
'terminalView.bottomDock.resizeAria': 'ターミナルパネルのサイズを変更',
'terminalView.bottomDock.restoreTitle': 'ターミナルパネルの高さを復元',
'terminalView.bottomDock.restoreAria': 'ターミナルパネルの高さを復元',
'terminalView.bottomDock.expandTitle': 'ターミナルパネルを展開',
'terminalView.bottomDock.expandAria': 'ターミナルパネルを展開',
'terminalView.bottomDock.closeTitle': 'ターミナルパネルを閉じる',
'terminalView.bottomDock.closeAria': 'ターミナルパネルを閉じる',
'terminalView.viewport.inputAria': 'ターミナル入力',
'directoryExplorerDialog.title': 'プロジェクトディレクトリを追加',
'directoryExplorerDialog.description': 'プロジェクトとして追加するフォルダを選択してください。',
@@ -1572,10 +1591,9 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': 'チャット入力にフォーカス',
'helpDialog.item.togglePromptNavigator': 'プロンプトナビゲーターの表示切替',
'helpDialog.item.abortActiveRun': 'アクティブな実行を中止(ダブルプレス)',
'helpDialog.item.toggleRightSidebar': '右サイドバーの切り替え',
'helpDialog.item.openRightSidebarGitTab': '右サイドバーのGitタブを開く',
'helpDialog.item.openRightSidebarFilesTab': '右サイドバーのファイルタブを開く',
'helpDialog.item.cycleRightSidebarTab': '右サイドバータブの切り替え',
'helpDialog.item.toggleRightSidebar': 'コンテキストパネルの表示切替',
'helpDialog.item.openRightSidebarGitTab': 'Git サーフェスを開く',
'helpDialog.item.openRightSidebarFilesTab': 'ファイルサーフェスを開く',
'helpDialog.item.toggleTerminalDock': 'ターミナルドックの切り替え',
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
@@ -2195,7 +2213,6 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.addProject': 'プロジェクトを追加',
'commandPalette.item.showSessionSwitcher': 'セッションスイッチャーを表示',
'commandPalette.item.toggleSidebar': 'サイドバーの切り替え',
'commandPalette.item.toggleRightSidebar': '右サイドバーの切り替え',
'commandPalette.item.showContextUsage': 'コンテキスト使用量を表示',
'commandPalette.item.toggleTerminal': 'ターミナルの切り替え',
'commandPalette.item.openSettings': '設定を開く...',
@@ -1062,10 +1062,9 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '터미널 dock 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '오른쪽 사이드바 토글',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '오른쪽 사이드바 Git 열기',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '오른쪽 사이드바 Files 탭 열기',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '오른쪽 사이드바 탭 순환',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
'chat.chatInput.terminalContext': '{terminal}, {start}-{end}행',
'chat.chatInput.terminalContextRemove': '터미널 컨텍스트 제거',
'chat.chatInput.prCommentContext': 'PR 댓글',
'chat.chatInput.prCommentContextRemove': 'PR 댓글 컨텍스트 제거',
'chat.chatInput.prCheckContext': '실패한 PR 체크',
'chat.chatInput.prCheckContextRemove': 'PR 체크 컨텍스트 제거',
'common.loading': '로딩 중...',
'common.unavailable': '사용할 수 없음',
'common.language.english': '영어',
@@ -709,6 +713,8 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noIdentity': '인증 정보 없음',
'gitView.header.noProfiles': '적용할 프로필 없음',
'gitView.header.repositoryViews': '저장소 보기',
'gitView.header.updateBranch': '브랜치 업데이트',
'gitView.header.openPullRequest': '풀 리퀘스트 열기',
'gitView.header.removeRemoteAria': '리모트 제거',
'gitView.header.removeRemoteTitle': '리모트 제거',
'gitView.header.upstreamSynced': '동기화됨',
@@ -760,6 +766,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원',
'gitView.history.renderDiffAnyway': '그래도 렌더링',
'gitView.history.title': '히스토리',
'gitView.history.refresh': '새로고침',
'gitView.graph.title': '그래프',
'gitView.integrate.checking': '확인 중…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다',
@@ -894,15 +901,15 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.actions.openOnGitHubAria': 'GitHub에서 PR 열기',
'gitView.pr.actions.openSettings': '설정 열기',
'gitView.pr.actions.repo': '레포지토리',
'gitView.pr.actions.resolveFailedChecks': '실패한 체크 해결',
'gitView.pr.actions.resolveFailedChecksAria': '실패한 검사를 에이전트에 보내기',
'gitView.pr.actions.resolveFailedChecks': '실패한 체크 첨부',
'gitView.pr.actions.resolveFailedChecksAria': '실패한 체크를 채팅 컨텍스트에 첨부',
'gitView.pr.actions.savePr': '저장 PR',
'gitView.pr.actions.savePrAria': 'PR 변경 사항 저장',
'gitView.pr.actions.sendCommentToAgent': '보내기 댓글로 에이전트',
'gitView.pr.actions.sendCommentToAgentAria': '이 댓글을 에이전트에 보내기',
'gitView.pr.actions.sendToAgent': '보내기로 에이전트',
'gitView.pr.actions.shareComments': '댓글 공유',
'gitView.pr.actions.shareCommentsAria': '보내기 PR 댓글로 에이전트',
'gitView.pr.actions.sendCommentToAgent': '댓글을 채팅 컨텍스트에 첨부',
'gitView.pr.actions.sendCommentToAgentAria': '이 댓글을 채팅 컨텍스트에 첨부',
'gitView.pr.actions.sendToAgent': '채팅에 첨부',
'gitView.pr.actions.shareComments': '댓글 첨부',
'gitView.pr.actions.shareCommentsAria': '풀 리퀘스트 댓글을 채팅 컨텍스트에 첨부',
'gitView.pr.actions.toggleDraftAria': '드래프트 상태 전환',
'gitView.pr.actions.refresh': 'PR 상태 새로고침',
'gitView.pr.actions.refreshAria': '풀 리퀘스트 상태 새로고침',
@@ -940,6 +947,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.noMergePermission': '이 PR을 병합할 권한이 없습니다.',
'gitView.pr.notMergeable': '병합 불가',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': '개요',
'gitView.pr.segment.checks': '체크',
'gitView.pr.segment.comments': '댓글',
'gitView.pr.comments.addAll': '모두 채팅에 첨부',
'gitView.pr.placeholder.additionalContext': '제약 사항, 배포 메모 또는 리뷰어 참고사항을 입력하세요…',
'gitView.pr.placeholder.description': '변경 사항 설명…',
'gitView.pr.placeholder.main': 'PR 요약',
@@ -974,9 +985,6 @@ export const dict: Record<I18nKey, string> = {
'gitView.pullRequest.createHint': '이 브랜치에서 PR을 만들고 관리하세요.',
'gitView.pullRequest.availableOnFeatureBranches': '현재 브랜치에서 PR을 열 수 있을 때 사용할 수 있습니다.',
'gitView.pullRequest.title': 'PR',
'gitView.tabs.commit': '커밋',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': '업데이트',
'gitView.tabs.worktree': '워크트리',
'gitView.toast.abortOperationFailed': '작업 중단 실패',
'gitView.toast.appliedIdentity': '인증 정보를 적용했습니다: {name}',
@@ -1043,9 +1051,26 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.stagedDiff': 'Staged Diff',
'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': '계획',
'contextPanel.mode.pr': '풀 리퀘스트',
'contextPanel.mode.context': '컨텍스트',
'contextPanel.mode.preview': '미리보기',
'contextPanel.mode.browser': '브라우저',
'contextRail.aria.rail': '패널 서피스',
'contextPanel.editorEmpty.title': '열린 파일 없음',
'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.',
'contextRail.surface.editor.description': '프로젝트 파일 편집',
'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트',
'contextRail.surface.terminal.description': '내장 터미널',
'contextRail.surface.diff.description': '작업 중인 변경 사항 검토',
'contextRail.surface.plan.description': '현재 계획 보기',
'contextRail.surface.pr.description': '현재 브랜치의 풀 리퀘스트를 생성, 검토, 병합',
'contextRail.surface.notes.description': '프로젝트의 노트, 할 일, 계획',
'contextRail.surface.context.description': '세션 컨텍스트 및 토큰 사용량',
'contextRail.surface.browser.description': '내장 웹 브라우저',
'contextRail.surface.preview.description': '개발 서버 미리보기',
'contextRail.surface.chat.description': '나란히 연 세션',
'contextRail.surface.notes': '프로젝트 노트',
'contextRail.editorTree.toggle': '파일 트리 표시 전환',
'contextPanel.browser.open': '브라우저 패널 열기',
'contextPanel.browser.addressAria': '브라우저 주소',
'contextPanel.browser.empty': '웹 브라우저',
@@ -1133,6 +1158,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.actions.newFileTitle': '새 파일',
'sidebarFilesTree.actions.newFolderTitle': '새 폴더',
'sidebarFilesTree.actions.refreshTitle': '새로고침',
'sidebarFilesTree.actions.collapseAllTitle': '모든 폴더 접기',
'sidebarFilesTree.actions.fileMenuTitle': '파일 메뉴',
'sidebarFilesTree.state.searching': '검색 중…',
'sidebarFilesTree.state.loading': '로드 중…',
@@ -1494,13 +1520,6 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.enterAria': 'Enter',
'terminalView.tabs.closeTabTitle': '탭 닫기',
'terminalView.tabs.newTabTitle': '새 탭',
'terminalView.bottomDock.resizeAria': '터미널 패널 크기 조정',
'terminalView.bottomDock.restoreTitle': '터미널 패널 높이 복원',
'terminalView.bottomDock.restoreAria': '터미널 패널 높이 복원',
'terminalView.bottomDock.expandTitle': '펼치기 터미널 패널',
'terminalView.bottomDock.expandAria': '펼치기 터미널 패널',
'terminalView.bottomDock.closeTitle': '터미널 패널 닫기',
'terminalView.bottomDock.closeAria': '터미널 패널 닫기',
'terminalView.viewport.inputAria': '터미널 입력',
'directoryExplorerDialog.title': '프로젝트 디렉터리 추가',
'directoryExplorerDialog.description': '프로젝트로 추가할 폴더를 선택하세요.',
@@ -1578,10 +1597,9 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': '채팅 입력창으로 포커스 이동',
'helpDialog.item.togglePromptNavigator': '프롬프트 탐색기 표시/숨기기',
'helpDialog.item.abortActiveRun': '활성 실행 중단(두 번 누르기)',
'helpDialog.item.toggleRightSidebar': '오른쪽 사이드바 전환',
'helpDialog.item.openRightSidebarGitTab': '오른쪽 사이드바 Git 열기',
'helpDialog.item.openRightSidebarFilesTab': '오른쪽 사이드바 파일 탭 열기',
'helpDialog.item.cycleRightSidebarTab': '오른쪽 사이드바 탭 순환',
'helpDialog.item.toggleRightSidebar': '컨텍스트 패널 표시 전환',
'helpDialog.item.openRightSidebarGitTab': 'Git 서피스 열기',
'helpDialog.item.openRightSidebarFilesTab': '파일 서피스 열기',
'helpDialog.item.toggleTerminalDock': '터미널 독 전환',
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
@@ -2196,7 +2214,6 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.addProject': '프로젝트 추가',
'commandPalette.item.showSessionSwitcher': '세션 전환기 표시',
'commandPalette.item.toggleSidebar': '토글 사이드바',
'commandPalette.item.toggleRightSidebar': '오른쪽 사이드바 전환',
'commandPalette.item.showContextUsage': '컨텍스트 사용량 표시',
'commandPalette.item.toggleTerminal': '토글 터미널',
'commandPalette.item.openSettings': '설정... 열기',
@@ -793,7 +793,6 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Przełącz ulubiony model wstecz',
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Otwórz wybór modelu',
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Przełącz ulubiony model w przód',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': 'Przełącz zakładkę prawego paska bocznego',
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Przełącz zakładkę usług',
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Przełącz motyw',
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Przełącz agenta',
@@ -806,11 +805,11 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń',
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz zakładkę Pliki w prawym pasku bocznym',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz zakładkę Git w prawym pasku bocznym',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz prawy pasek boczny',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu',
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Przełącz dokowanie terminala',
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, wiersze {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Usuń kontekst terminala',
'chat.chatInput.prCommentContext': 'Komentarze PR',
'chat.chatInput.prCommentContextRemove': 'Usuń kontekst komentarzy PR',
'chat.chatInput.prCheckContext': 'Nieudane kontrole PR',
'chat.chatInput.prCheckContextRemove': 'Usuń kontekst kontroli PR',
'common.loading': 'Ładowanie...',
'common.unavailable': 'Niedostępne',
@@ -1362,7 +1366,6 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.openSettings': 'Otwórz ustawienia...',
'commandPalette.item.showContextUsage': 'Pokaż użycie kontekstu',
'commandPalette.item.showSessionSwitcher': 'Pokaż przełącznik sesji',
'commandPalette.item.toggleRightSidebar': 'Przełącz prawy panel boczny',
'commandPalette.item.toggleSidebar': 'Przełącz panel boczny',
'commandPalette.item.toggleTerminal': 'Przełącz terminal',
'commandPalette.session.untitled': 'Nienazwana sesja',
@@ -1379,8 +1382,25 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.files': 'Pliki',
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.pr': 'Pull Request',
'contextPanel.mode.preview': 'Podgląd',
'contextPanel.mode.browser': 'Przeglądarka',
'contextRail.aria.rail': 'Powierzchnie panelu',
'contextPanel.editorEmpty.title': 'Brak otwartego pliku',
'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.',
'contextRail.surface.editor.description': 'Edytuj pliki projektu',
'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty',
'contextRail.surface.terminal.description': 'Wbudowany terminal',
'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany',
'contextRail.surface.plan.description': 'Zobacz bieżący plan',
'contextRail.surface.pr.description': 'Twórz, przeglądaj i scalaj pull request bieżącej gałęzi',
'contextRail.surface.notes.description': 'Notatki, zadania i plany projektu',
'contextRail.surface.context.description': 'Kontekst sesji i zużycie tokenów',
'contextRail.surface.browser.description': 'Wbudowana przeglądarka',
'contextRail.surface.preview.description': 'Podgląd serwera deweloperskiego',
'contextRail.surface.chat.description': 'Sesja otwarta obok',
'contextRail.surface.notes': 'Notatki projektu',
'contextRail.editorTree.toggle': 'Przełącz drzewo plików',
'contextPanel.browser.open': 'Otwórz panel przeglądarki',
'contextPanel.browser.addressAria': 'Adres przeglądarki',
'contextPanel.browser.empty': 'Przeglądarka internetowa',
@@ -1842,6 +1862,8 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noIdentity': 'No identity',
'gitView.header.noProfiles': 'No profiles available to apply.',
'gitView.header.repositoryViews': 'Widoki repozytorium',
'gitView.header.updateBranch': 'Zaktualizuj gałąź',
'gitView.header.openPullRequest': 'Otwórz pull request',
'gitView.header.removeRemoteAria': 'Usuń remote {name}',
'gitView.header.removeRemoteTitle': 'Usuń remote {name}',
'gitView.header.upstreamSynced': 'zsynchronizowano',
@@ -1894,6 +1916,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
'gitView.history.renderDiffAnyway': 'Render anyway',
'gitView.history.title': 'History',
'gitView.history.refresh': 'Odśwież',
'gitView.graph.title': 'Graf',
'gitView.integrate.checking': 'Sprawdzanie…',
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany',
@@ -1960,15 +1983,15 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.actions.refresh': 'Odśwież status PR',
'gitView.pr.actions.refreshAria': 'Odśwież status pull requesta',
'gitView.pr.actions.repo': 'Repozytorium',
'gitView.pr.actions.resolveFailedChecks': 'Rozwiąż nieudane checki',
'gitView.pr.actions.resolveFailedChecksAria': 'Wyślij nieudane checki do agenta',
'gitView.pr.actions.resolveFailedChecks': 'Dołącz nieudane kontrole',
'gitView.pr.actions.resolveFailedChecksAria': 'Dołącz nieudane kontrole do kontekstu czatu',
'gitView.pr.actions.savePr': 'Zapisz PR',
'gitView.pr.actions.savePrAria': 'Zapisz zmiany pull requesta',
'gitView.pr.actions.sendCommentToAgent': 'Wyślij komentarz do agenta',
'gitView.pr.actions.sendCommentToAgentAria': 'Wyślij ten komentarz do agenta',
'gitView.pr.actions.sendToAgent': 'Wyślij do agenta',
'gitView.pr.actions.shareComments': 'Udostępnij komentarze',
'gitView.pr.actions.shareCommentsAria': 'Wyślij komentarze pull requesta do agenta',
'gitView.pr.actions.sendCommentToAgent': 'Dołącz komentarz do kontekstu czatu',
'gitView.pr.actions.sendCommentToAgentAria': 'Dołącz ten komentarz do kontekstu czatu',
'gitView.pr.actions.sendToAgent': 'Dołącz do czatu',
'gitView.pr.actions.shareComments': 'Dołącz komentarze',
'gitView.pr.actions.shareCommentsAria': 'Dołącz komentarze pull requesta do kontekstu czatu',
'gitView.pr.actions.toggleDraftAria': 'Przełącz stan szkicu',
'gitView.pr.additionalContext.added': 'Dodano',
'gitView.pr.additionalContext.hint': 'Dodaj dodatkowy kontekst, aby poprawić jakość recenzji.',
@@ -2003,6 +2026,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.noMergePermission': 'Nie masz uprawnień do scalenia tego pull requesta.',
'gitView.pr.notMergeable': 'Nie można scalić',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': 'Przegląd',
'gitView.pr.segment.checks': 'Kontrole',
'gitView.pr.segment.comments': 'Komentarze',
'gitView.pr.comments.addAll': 'Dołącz wszystko do czatu',
'gitView.pr.placeholder.additionalContext': 'Ograniczenia, notatki wdrożeniowe lub kontekst dla recenzenta...',
'gitView.pr.placeholder.description': 'Opisz zmianę...',
'gitView.pr.placeholder.main': 'Podsumuj swój pull request',
@@ -2054,9 +2081,6 @@ export const dict: Record<I18nKey, string> = {
'gitView.sync.push': 'Wypchnij',
'gitView.sync.pushTooltip': 'Wypchnij zmiany',
'gitView.sync.pushTooltipAhead': 'Wypchnij zmiany ({count} do przodu)',
'gitView.tabs.commit': 'Commit',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': 'Aktualizuj',
'gitView.tabs.worktree': 'Drzewo pracy',
'gitView.toast.abortOperationFailed': 'Nie udało się przerwać operacji',
'gitView.toast.appliedIdentity': 'Zastosowano tożsamość: {name}',
@@ -2204,7 +2228,6 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.createNewSession': 'Utwórz nową sesję',
'helpDialog.item.createNewWorktreeDraft': 'Utwórz nowy szkic drzewa pracy',
'helpDialog.item.cycleAgent': 'Przełącz agenta (w polu czatu)',
'helpDialog.item.cycleRightSidebarTab': 'Przełącz kartę prawego panelu',
'helpDialog.item.cycleServicesTab': 'Przełącz kartę usług',
'helpDialog.item.cycleTheme': 'Przełącz motyw (Jasny → Ciemny → Systemowy)',
'helpDialog.item.cycleThinkingVariant': 'Przełącz wariant myślenia (skrót globalny)',
@@ -2214,13 +2237,13 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.newWindow': 'Nowe okno (tylko desktop)',
'helpDialog.item.openCommandPalette': 'Otwórz paletę poleceń',
'helpDialog.item.openModelSelector': 'Otwórz selektor modeli',
'helpDialog.item.openRightSidebarFilesTab': 'Otwórz kartę Pliki w prawym panelu',
'helpDialog.item.openRightSidebarGitTab': 'Otwórz kartę Git w prawym panelu',
'helpDialog.item.openRightSidebarFilesTab': 'Otwórz powierzchnię plików',
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
'helpDialog.item.openSettings': 'Otwórz ustawienia',
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
'helpDialog.item.switchProject': 'Przełącz projekt',
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
'helpDialog.item.toggleRightSidebar': 'Przełącz prawy panel boczny',
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji',
'helpDialog.item.toggleTerminalDock': 'Przełącz dolny terminal',
@@ -2584,6 +2607,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.actions.newFileTitle': 'Nowy plik',
'sidebarFilesTree.actions.newFolderTitle': 'Nowy folder',
'sidebarFilesTree.actions.refreshTitle': 'Odśwież',
'sidebarFilesTree.actions.collapseAllTitle': 'Zwiń wszystkie foldery',
'sidebarFilesTree.actions.fileMenuTitle': 'Menu pliku',
'sidebarFilesTree.dialog.cancel': 'Anuluj',
'sidebarFilesTree.dialog.confirm': 'Potwierdź',
@@ -2627,13 +2651,6 @@ export const dict: Record<I18nKey, string> = {
'terminalView.actions.hardRestart': 'Hard Restart',
'terminalView.actions.hardRestartTitle': 'Force kill and create fresh session',
'terminalView.actions.retry': 'Ponów',
'terminalView.bottomDock.closeAria': 'Close terminal panel',
'terminalView.bottomDock.closeTitle': 'Close terminal panel',
'terminalView.bottomDock.expandAria': 'Expand terminal panel',
'terminalView.bottomDock.expandTitle': 'Expand terminal panel',
'terminalView.bottomDock.resizeAria': 'Resize terminal panel',
'terminalView.bottomDock.restoreAria': 'Restore terminal panel height',
'terminalView.bottomDock.restoreTitle': 'Restore terminal panel height',
'terminalView.empty.noWorkingDirectory': 'No working directory available for terminal.',
'terminalView.empty.noWorkingDirectoryForSession': 'No working directory available for this session.',
'terminalView.empty.selectSession': 'Select a session to open the terminal.',
@@ -1062,10 +1062,9 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar ou ocultar painel de terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": "Mostrar ou ocultar barra lateral direita",
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": "Abrir aba de Git em barra lateral direita",
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": "Abrir aba de Arquivos em barra lateral direita",
"settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label": "Alternar aba da barra lateral direita",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, linhas {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Remover contexto do terminal',
'chat.chatInput.prCommentContext': 'Comentários do PR',
'chat.chatInput.prCommentContextRemove': 'Remover contexto de comentários do PR',
'chat.chatInput.prCheckContext': 'Verificações do PR com falha',
'chat.chatInput.prCheckContextRemove': 'Remover contexto de verificações do PR',
"common.loading": "Carregando...",
"common.unavailable": "Indisponível",
"common.language.english": "Inglês",
@@ -709,6 +713,8 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.noIdentity": "Sem identidade",
"gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.",
"gitView.header.repositoryViews": "Visualizações do repositório",
"gitView.header.updateBranch": "Atualizar branch",
"gitView.header.openPullRequest": "Abrir pull request",
"gitView.header.removeRemoteAria": "Excluir remoto",
"gitView.header.removeRemoteTitle": "Excluir remoto",
"gitView.header.upstreamSynced": "sincronizado",
@@ -760,6 +766,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.history.renamedNoDiff": "Arquivo renomeado — diff não suportado",
"gitView.history.renderDiffAnyway": "Renderizar mesmo assim",
"gitView.history.title": "Histórico",
"gitView.history.refresh": "Atualizar",
"gitView.graph.title": "Grafo",
"gitView.integrate.checking": "Verificando…",
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
@@ -894,15 +901,15 @@ export const dict: Record<I18nKey, string> = {
"gitView.pr.actions.openOnGitHubAria": "Abrir PR em GitHub",
"gitView.pr.actions.openSettings": "Abrir configurações",
"gitView.pr.actions.repo": "Repositório",
"gitView.pr.actions.resolveFailedChecks": "Resolver checks com falha",
"gitView.pr.actions.resolveFailedChecksAria": "Enviar checks com falha ao agente",
"gitView.pr.actions.resolveFailedChecks": "Anexar verificações com falha",
"gitView.pr.actions.resolveFailedChecksAria": "Anexar verificações com falha ao contexto do chat",
"gitView.pr.actions.savePr": "Salvar PR",
"gitView.pr.actions.savePrAria": "Salvar alterações da PR",
"gitView.pr.actions.sendCommentToAgent": "Enviar comentário ao agente",
"gitView.pr.actions.sendCommentToAgentAria": "Enviar este comentário ao agente",
"gitView.pr.actions.sendToAgent": "Enviar ao agente",
"gitView.pr.actions.shareComments": "Compartilhar comentários",
"gitView.pr.actions.shareCommentsAria": "Enviar comentários da PR ao agente",
"gitView.pr.actions.sendCommentToAgent": "Anexar comentário ao contexto do chat",
"gitView.pr.actions.sendCommentToAgentAria": "Anexar este comentário ao contexto do chat",
"gitView.pr.actions.sendToAgent": "Anexar ao chat",
"gitView.pr.actions.shareComments": "Anexar comentários",
"gitView.pr.actions.shareCommentsAria": "Anexar comentários do pull request ao contexto do chat",
"gitView.pr.actions.toggleDraftAria": "Alternar status de rascunho",
"gitView.pr.actions.refresh": "Atualizar status da PR",
"gitView.pr.actions.refreshAria": "Atualizar status do pull request",
@@ -940,6 +947,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.pr.noMergePermission": "Você não tem permissão para fazer merge desta PR.",
"gitView.pr.notMergeable": "Não é possível fazer merge",
"gitView.pr.numberLabel": "PR # {number}",
"gitView.pr.segment.overview": "Visão geral",
"gitView.pr.segment.checks": "Verificações",
"gitView.pr.segment.comments": "Comentários",
"gitView.pr.comments.addAll": "Anexar tudo ao chat",
"gitView.pr.placeholder.additionalContext": "Quaisquer restrições, notas de lançamento ou contexto para revisão...",
"gitView.pr.placeholder.description": "Descreva a alteração...",
"gitView.pr.placeholder.main": "Resume seu PR",
@@ -974,9 +985,6 @@ export const dict: Record<I18nKey, string> = {
"gitView.pullRequest.createHint": "Crie e gerencie PRs desta branch.",
"gitView.pullRequest.availableOnFeatureBranches": "Disponível quando a branch atual pode abrir uma PR.",
"gitView.pullRequest.title": "PR",
"gitView.tabs.commit": "Commit",
"gitView.tabs.pr": "PR",
"gitView.tabs.update": "Atualizar",
"gitView.tabs.worktree": "Worktree",
"gitView.toast.abortOperationFailed": "Não foi possível abortar a operación",
"gitView.toast.appliedIdentity": "Identidad aplicada: {name}",
@@ -1043,9 +1051,26 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.mode.stagedDiff": "Staged Diff",
"contextPanel.mode.workingDiff": "Working Diff",
"contextPanel.mode.plan": "Plano",
"contextPanel.mode.pr": "Pull Request",
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Prévia",
"contextPanel.mode.browser": "Navegador",
"contextRail.aria.rail": "Superfícies do painel",
"contextPanel.editorEmpty.title": "Nenhum arquivo aberto",
"contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.",
"contextRail.surface.editor.description": "Editar arquivos do projeto",
"contextRail.surface.git.description": "Commits, branches e pull requests",
"contextRail.surface.terminal.description": "Terminal integrado",
"contextRail.surface.diff.description": "Revisar alterações em andamento",
"contextRail.surface.plan.description": "Ver o plano atual",
"contextRail.surface.pr.description": "Crie, revise e faça merge do pull request do branch atual",
"contextRail.surface.notes.description": "Notas, tarefas e planos do projeto",
"contextRail.surface.context.description": "Contexto da sessão e uso de tokens",
"contextRail.surface.browser.description": "Navegador web integrado",
"contextRail.surface.preview.description": "Pré-visualização do servidor de desenvolvimento",
"contextRail.surface.chat.description": "Sessão aberta lado a lado",
"contextRail.surface.notes": "Notas do projeto",
"contextRail.editorTree.toggle": "Alternar árvore de arquivos",
"contextPanel.browser.open": "Abrir painel do navegador",
"contextPanel.browser.addressAria": "Endereço do navegador",
"contextPanel.browser.empty": "Navegador web",
@@ -1096,6 +1121,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.actions.newFileTitle": "Novo arquivo",
"sidebarFilesTree.actions.newFolderTitle": "Nova pasta",
"sidebarFilesTree.actions.refreshTitle": "Atualizar",
"sidebarFilesTree.actions.collapseAllTitle": "Recolher todas as pastas",
"sidebarFilesTree.actions.fileMenuTitle": "Menu do arquivo",
"sidebarFilesTree.state.searching": "Buscando...",
"sidebarFilesTree.state.loading": "Carregando...",
@@ -1470,13 +1496,6 @@ export const dict: Record<I18nKey, string> = {
"terminalView.quickKeys.enterAria": "Enter",
"terminalView.tabs.closeTabTitle": "Fechar aba",
"terminalView.tabs.newTabTitle": "Nova aba",
"terminalView.bottomDock.resizeAria": "Ajustar tamanho do painel de terminal",
"terminalView.bottomDock.restoreTitle": "Restaurar altura do painel de terminal",
"terminalView.bottomDock.restoreAria": "Restaurar altura do painel de terminal",
"terminalView.bottomDock.expandTitle": "Expandir painel de terminal",
"terminalView.bottomDock.expandAria": "Expandir painel de terminal",
"terminalView.bottomDock.closeTitle": "Fechar painel de terminal",
"terminalView.bottomDock.closeAria": "Fechar painel de terminal",
"terminalView.viewport.inputAria": "Entrada de terminal",
"directoryExplorerDialog.title": "Adicionar diretório de projeto",
"directoryExplorerDialog.description": "Escolha uma pasta para adicionar como projeto.",
@@ -1554,10 +1573,9 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.focusChatInput": "Focar entrada do chat",
"helpDialog.item.togglePromptNavigator": "Mostrar ou ocultar navegador de prompts",
"helpDialog.item.abortActiveRun": "Interromper execução ativa (duplo clique)",
"helpDialog.item.toggleRightSidebar": "Mostrar ou ocultar barra lateral direita",
"helpDialog.item.openRightSidebarGitTab": "Abrir aba de Git em barra lateral direita",
"helpDialog.item.openRightSidebarFilesTab": "Abrir aba de arquivos em barra lateral direita",
"helpDialog.item.cycleRightSidebarTab": "Alternar aba da barra lateral direita",
"helpDialog.item.toggleRightSidebar": 'Alternar painel de contexto',
"helpDialog.item.openRightSidebarGitTab": 'Abrir superfície do Git',
"helpDialog.item.openRightSidebarFilesTab": 'Abrir superfície de arquivos',
"helpDialog.item.toggleTerminalDock": "Mostrar ou ocultar dock de terminal",
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
@@ -2162,7 +2180,6 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.addProject": "Adicionar projeto",
"commandPalette.item.showSessionSwitcher": "Mostrar seletor de sessões",
"commandPalette.item.toggleSidebar": "Mostrar ou ocultar barra lateral",
"commandPalette.item.toggleRightSidebar": "Mostrar ou ocultar barra lateral direita",
"commandPalette.item.showContextUsage": "Mostrar uso do contexto",
"commandPalette.item.toggleTerminal": "Mostrar ou ocultar terminal",
"commandPalette.item.openSettings": "Abrir configurações...",
@@ -1062,10 +1062,9 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Перемкнути панель терміналу",
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал",
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": "Перемкнути праву бічну панель",
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": "Відкрити вкладку Git правої бічної панелі",
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": "Відкрити вкладку «Файли» правої бічної панелі",
"settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label": "Перемкнути вкладку правої бічної панелі",
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
+45 -28
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
'chat.chatInput.terminalContext': '{terminal}, рядки {start}-{end}',
'chat.chatInput.terminalContextRemove': 'Видалити контекст термінала',
'chat.chatInput.prCommentContext': 'Коментарі PR',
'chat.chatInput.prCommentContextRemove': 'Прибрати контекст коментарів PR',
'chat.chatInput.prCheckContext': 'Зафейлені чеки PR',
'chat.chatInput.prCheckContextRemove': 'Прибрати контекст чеків PR',
"common.loading": "Завантаження...",
"common.unavailable": "Недоступно",
"common.language.english": "англійська",
@@ -453,7 +457,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.rename.save": "Зберегти назву сесії",
"sessions.sidebar.session.rename.cancel": "Скасувати перейменування сесії",
"sessions.sidebar.session.menu.unpin": "Відкріпити сесія",
"sessions.sidebar.session.menu.pin": "Закріпити сесія",
"sessions.sidebar.session.menu.pin": "Прикріпити сесія",
"sessions.sidebar.session.menu.share": "Поділитися",
"sessions.sidebar.session.menu.copied": "Скопійовано",
"sessions.sidebar.session.menu.copyLink": "Копіювати посилання",
@@ -709,6 +713,8 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.noIdentity": "Ідентичність не вибрано",
"gitView.header.noProfiles": "Немає доступних профілів для застосування.",
"gitView.header.repositoryViews": "Перегляди репозиторію",
"gitView.header.updateBranch": "Оновити гілку",
"gitView.header.openPullRequest": "Відкрити pull request",
"gitView.header.removeRemoteAria": "Видалити remote",
"gitView.header.removeRemoteTitle": "Видалити remote",
"gitView.header.upstreamSynced": "синхронізовано",
@@ -760,6 +766,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується",
"gitView.history.renderDiffAnyway": "Показати все одно",
"gitView.history.title": "Історія",
"gitView.history.refresh": "Оновити",
"gitView.graph.title": "Граф",
"gitView.integrate.checking": "Перевірка…",
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано",
@@ -894,15 +901,15 @@ export const dict: Record<I18nKey, string> = {
"gitView.pr.actions.openOnGitHubAria": "Відкрити PR на GitHub",
"gitView.pr.actions.openSettings": "Відкрити налаштування",
"gitView.pr.actions.repo": "Репозиторій",
"gitView.pr.actions.resolveFailedChecks": "Виправити невдалі перевірки",
"gitView.pr.actions.resolveFailedChecksAria": "Надіслати невдалі перевірки агенту",
"gitView.pr.actions.resolveFailedChecks": "Прикріпити зафейлені чеки",
"gitView.pr.actions.resolveFailedChecksAria": "Прикріпити зафейлені чеки до контексту чату",
"gitView.pr.actions.savePr": "Зберегти PR",
"gitView.pr.actions.savePrAria": "Зберегти зміни PR",
"gitView.pr.actions.sendCommentToAgent": "Надіслати коментар агенту",
"gitView.pr.actions.sendCommentToAgentAria": "Надіслати цей коментар агенту",
"gitView.pr.actions.sendToAgent": "Надіслати агенту",
"gitView.pr.actions.shareComments": оділитися коментарями",
"gitView.pr.actions.shareCommentsAria": "Надсилати агенту коментарі PR",
"gitView.pr.actions.sendCommentToAgent": "Прикріпити коментар до контексту чату",
"gitView.pr.actions.sendCommentToAgentAria": "Прикріпити цей коментар до контексту чату",
"gitView.pr.actions.sendToAgent": "Прикріпити в чат",
"gitView.pr.actions.shareComments": рикріпити коментарі",
"gitView.pr.actions.shareCommentsAria": "Прикріпити коментарі pull request до контексту чату",
"gitView.pr.actions.toggleDraftAria": "Перемкнути стан чернетки",
"gitView.pr.actions.refresh": "Оновити статус PR",
"gitView.pr.actions.refreshAria": "Оновити статус pull request",
@@ -940,6 +947,10 @@ export const dict: Record<I18nKey, string> = {
"gitView.pr.noMergePermission": "Ви не маєте дозволу на об’єднання цього запиту.",
"gitView.pr.notMergeable": "Неможливо злити",
"gitView.pr.numberLabel": "PR #{number}",
"gitView.pr.segment.overview": "Огляд",
"gitView.pr.segment.checks": "Перевірки",
"gitView.pr.segment.comments": "Коментарі",
"gitView.pr.comments.addAll": "Прикріпити все в чат",
"gitView.pr.placeholder.additionalContext": "Будь-які обмеження, примітки щодо розгортання чи контекст рецензента...",
"gitView.pr.placeholder.description": "Опишіть зміну...",
"gitView.pr.placeholder.main": "Узагальніть свій PR",
@@ -974,9 +985,6 @@ export const dict: Record<I18nKey, string> = {
"gitView.pullRequest.createHint": "Створюйте PR з цієї гілки та керуйте ними.",
"gitView.pullRequest.availableOnFeatureBranches": "Доступно, коли поточна гілка може відкрити PR.",
"gitView.pullRequest.title": "PR",
"gitView.tabs.commit": "Коміт",
"gitView.tabs.pr": "PR",
"gitView.tabs.update": "Оновлення",
"gitView.tabs.worktree": "Worktree",
"gitView.toast.abortOperationFailed": "Не вдалося перервати операцію",
"gitView.toast.appliedIdentity": "Застосовано ідентичність: {name}",
@@ -1043,9 +1051,26 @@ export const dict: Record<I18nKey, string> = {
"contextPanel.mode.stagedDiff": "Staged Diff",
"contextPanel.mode.workingDiff": "Working Diff",
"contextPanel.mode.plan": "План",
"contextPanel.mode.pr": "Pull Request",
"contextPanel.mode.context": "Контекст",
"contextPanel.mode.preview": "Перегляд",
"contextPanel.mode.browser": "Браузер",
"contextRail.aria.rail": "Поверхні панелі",
"contextPanel.editorEmpty.title": "Файл не відкрито",
"contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.",
"contextRail.surface.editor.description": "Редагування файлів проєкту",
"contextRail.surface.git.description": "Коміти, гілки та pull request-и",
"contextRail.surface.terminal.description": "Вбудований термінал",
"contextRail.surface.diff.description": "Перегляд поточних змін",
"contextRail.surface.plan.description": "Перегляд поточного плану",
"contextRail.surface.pr.description": "Створюйте, переглядайте та зливайте pull request поточної гілки",
"contextRail.surface.notes.description": "Нотатки, задачі та плани проєкту",
"contextRail.surface.context.description": "Контекст сесії та використання токенів",
"contextRail.surface.browser.description": "Вбудований браузер",
"contextRail.surface.preview.description": "Перегляд дев-сервера",
"contextRail.surface.chat.description": "Сесія, відкрита поруч",
"contextRail.surface.notes": "Нотатки проєкту",
"contextRail.editorTree.toggle": "Перемкнути дерево файлів",
"contextPanel.browser.open": "Відкрити панель браузера",
"contextPanel.browser.addressAria": "Адреса браузера",
"contextPanel.browser.empty": "Веб-браузер",
@@ -1096,6 +1121,7 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.actions.newFileTitle": "Новий файл",
"sidebarFilesTree.actions.newFolderTitle": "Нова папка",
"sidebarFilesTree.actions.refreshTitle": "Оновити",
"sidebarFilesTree.actions.collapseAllTitle": "Згорнути всі папки",
"sidebarFilesTree.actions.fileMenuTitle": "Меню файлу",
"sidebarFilesTree.state.searching": "Пошук...",
"sidebarFilesTree.state.loading": "Завантаження...",
@@ -1470,13 +1496,6 @@ export const dict: Record<I18nKey, string> = {
"terminalView.quickKeys.enterAria": "Enter",
"terminalView.tabs.closeTabTitle": "Закрити вкладку",
"terminalView.tabs.newTabTitle": "Нова вкладка",
"terminalView.bottomDock.resizeAria": "Змінити розмір панелі терміналу",
"terminalView.bottomDock.restoreTitle": "Відновити висоту панелі терміналу",
"terminalView.bottomDock.restoreAria": "Відновити висоту панелі терміналу",
"terminalView.bottomDock.expandTitle": "Розгорнути панель терміналу",
"terminalView.bottomDock.expandAria": "Розгорнути панель терміналу",
"terminalView.bottomDock.closeTitle": "Закрити панель терміналу",
"terminalView.bottomDock.closeAria": "Закрити панель терміналу",
"terminalView.viewport.inputAria": "Ввід терміналу",
"directoryExplorerDialog.title": "Додати каталог проєкту",
"directoryExplorerDialog.description": "Виберіть папку, щоб додати її як проєкт.",
@@ -1513,7 +1532,7 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.toast.failedToSelectDirectory": "Не вдалося вибрати каталог",
"directoryExplorerDialog.toast.unknownError": "Сталася невідома помилка.",
"directoryTree.actions.createNewDirectory": "Створити новий каталог",
"directoryTree.actions.pinDirectory": "Закріпити каталог",
"directoryTree.actions.pinDirectory": "Прикріпити каталог",
"directoryTree.actions.unpinDirectory": "Відкріпити каталог",
"directoryTree.actions.createDirectory": "Створити каталог",
"directoryTree.actions.cancel": "Скасувати",
@@ -1554,10 +1573,9 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.focusChatInput": "Фокус на полі вводу чату",
"helpDialog.item.togglePromptNavigator": "Показати або приховати навігатор промптів",
"helpDialog.item.abortActiveRun": "Перервати активний запуск (подвійне натискання)",
"helpDialog.item.toggleRightSidebar": "Перемкнути праву бічну панель",
"helpDialog.item.openRightSidebarGitTab": "Відкрити вкладку Git у правій бічній панелі",
"helpDialog.item.openRightSidebarFilesTab": "Відкрити вкладку «Файли» у правій бічній панелі",
"helpDialog.item.cycleRightSidebarTab": "Перемкнути вкладку правої бічної панелі",
"helpDialog.item.toggleRightSidebar": 'Перемкнути контекстну панель',
"helpDialog.item.openRightSidebarGitTab": 'Відкрити поверхню Git',
"helpDialog.item.openRightSidebarFilesTab": 'Відкрити поверхню файлів',
"helpDialog.item.toggleTerminalDock": "Перемкнути панель терміналу",
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
@@ -1720,7 +1738,7 @@ export const dict: Record<I18nKey, string> = {
"chat.fileAttachment.actions.removeNamed": "Видалити {name}",
"chat.fileAttachment.actions.removeImage": "Видалити зображення",
"chat.fileAttachment.activeEditor.addFile": "Додати файл:{name} до контексту",
"chat.fileAttachment.activeEditor.pinSelection": "Закріпити вибір у контексті",
"chat.fileAttachment.activeEditor.pinSelection": "Прикріпити вибір у контексті",
"chat.fileAttachment.activeEditor.remove": "Видалити з контексту",
"chat.fileAttachment.openInDiagram": "Відкрити в перегляді діаграми",
"chat.pendingChanges.fileCountSingle": "Файл: {count}",
@@ -1891,7 +1909,7 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.actions.fork": "Відгалузити звідси",
"chat.messageBody.actions.copyMessageAria": "Копіювати текст повідомлення",
"chat.messageBody.actions.copyMessage": "Копіювати повідомлення",
"chat.messageBody.actions.pinContext": "Закріпити в контексті (збережеться після стиснення)",
"chat.messageBody.actions.pinContext": "Прикріпити в контексті (збережеться після стиснення)",
"chat.messageBody.actions.unpinContext": "Відкріпити від контексту (не збережеться після стиснення)",
"chat.messageBody.actions.contextPinFailed": "Не вдалося оновити закріплення в контексті",
"chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд",
@@ -2162,7 +2180,6 @@ export const dict: Record<I18nKey, string> = {
"commandPalette.item.addProject": "Додати проєкт",
"commandPalette.item.showSessionSwitcher": "Показати перемикач сесій",
"commandPalette.item.toggleSidebar": "Перемкнути бічну панель",
"commandPalette.item.toggleRightSidebar": "Перемкнути праву бічну панель",
"commandPalette.item.showContextUsage": "Показати використання контексту",
"commandPalette.item.toggleTerminal": "Перемкнути термінал",
"commandPalette.item.openSettings": "Відкрити налаштування...",
@@ -2474,9 +2491,9 @@ export const dict: Record<I18nKey, string> = {
"miniChat.status.busy": "Виконується",
"miniChat.status.retry": "Повторна спроба",
"miniChat.status.idle": "Очікує",
"miniChat.actions.pin": "Закріпити над іншими вікнами",
"miniChat.actions.pin": "Прикріпити над іншими вікнами",
"miniChat.actions.unpin": "Відкріпити вікно",
"miniChat.actions.pinAria": "Закріпити вікно Mini Chat",
"miniChat.actions.pinAria": "Прикріпити вікно Mini Chat",
"miniChat.actions.unpinAria": "Відкріпити вікно Mini Chat",
"miniChat.actions.openMain": "Відкрити в головному вікні",
"miniChat.actions.openMainAria": "Відкрити сесію в головному вікні",
@@ -1062,10 +1062,9 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切换终端停靠区',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换右侧边栏',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开右侧边栏 Git 标签',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开右侧边栏 Files 标签',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '轮换右侧边栏标签',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContextRemove': '移除终端上下文',
'chat.chatInput.prCommentContext': 'PR 评论',
'chat.chatInput.prCommentContextRemove': '移除 PR 评论上下文',
'chat.chatInput.prCheckContext': '失败的 PR 检查',
'chat.chatInput.prCheckContextRemove': '移除 PR 检查上下文',
'common.loading': '加载中...',
'common.unavailable': '不可用',
'common.language.english': 'English',
@@ -709,6 +713,8 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noIdentity': '无身份',
'gitView.header.noProfiles': '没有可应用的配置。',
'gitView.header.repositoryViews': '仓库视图',
'gitView.header.updateBranch': '更新分支',
'gitView.header.openPullRequest': '打开拉取请求',
'gitView.header.removeRemoteAria': '移除远程 {name}',
'gitView.header.removeRemoteTitle': '移除 {name}',
'gitView.header.upstreamSynced': '已同步',
@@ -760,6 +766,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.history.renamedNoDiff': '已重命名文件,不支持显示差异',
'gitView.history.renderDiffAnyway': '仍然渲染',
'gitView.history.title': '历史',
'gitView.history.refresh': '刷新',
'gitView.graph.title': '图谱',
'gitView.integrate.checking': '检查中…',
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
@@ -894,15 +901,15 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.actions.openOnGitHubAria': '在 GitHub 中打开拉取请求',
'gitView.pr.actions.openSettings': '打开设置',
'gitView.pr.actions.repo': '仓库',
'gitView.pr.actions.resolveFailedChecks': '处理失败检查',
'gitView.pr.actions.resolveFailedChecksAria': '将失败检查发送给智能体',
'gitView.pr.actions.resolveFailedChecks': '附加失败检查',
'gitView.pr.actions.resolveFailedChecksAria': '将失败检查附加到聊天上下文',
'gitView.pr.actions.savePr': '保存 PR',
'gitView.pr.actions.savePrAria': '保存拉取请求更改',
'gitView.pr.actions.sendCommentToAgent': '将评论发送给智能体',
'gitView.pr.actions.sendCommentToAgentAria': '将此评论发送给智能体',
'gitView.pr.actions.sendToAgent': '发送给智能体',
'gitView.pr.actions.shareComments': '分享评论',
'gitView.pr.actions.shareCommentsAria': '将拉取请求评论发送给智能体',
'gitView.pr.actions.sendCommentToAgent': '将评论附加到聊天上下文',
'gitView.pr.actions.sendCommentToAgentAria': '将此评论附加到聊天上下文',
'gitView.pr.actions.sendToAgent': '附加到聊天',
'gitView.pr.actions.shareComments': '附加评论',
'gitView.pr.actions.shareCommentsAria': '将拉取请求评论附加到聊天上下文',
'gitView.pr.actions.toggleDraftAria': '切换草稿状态',
'gitView.pr.actions.refresh': '刷新 PR 状态',
'gitView.pr.actions.refreshAria': '刷新拉取请求状态',
@@ -940,6 +947,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.noMergePermission': '你没有权限合并此拉取请求。',
'gitView.pr.notMergeable': '不可合并',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': '概览',
'gitView.pr.segment.checks': '检查',
'gitView.pr.segment.comments': '评论',
'gitView.pr.comments.addAll': '全部附加到聊天',
'gitView.pr.placeholder.additionalContext': '补充约束、发布说明或评审上下文...',
'gitView.pr.placeholder.description': '描述此变更...',
'gitView.pr.placeholder.main': '总结你的拉取请求',
@@ -974,9 +985,6 @@ export const dict: Record<I18nKey, string> = {
'gitView.pullRequest.createHint': '从当前分支创建并管理拉取请求。',
'gitView.pullRequest.availableOnFeatureBranches': '当前分支可以打开 PR 时可用。',
'gitView.pullRequest.title': '拉取请求',
'gitView.tabs.commit': '提交',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': '更新',
'gitView.tabs.worktree': '工作树',
'gitView.toast.abortOperationFailed': '中止操作失败',
'gitView.toast.appliedIdentity': '已应用身份:{name}',
@@ -1043,9 +1051,26 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.stagedDiff': 'Staged Diff',
'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': '计划',
'contextPanel.mode.pr': '拉取请求',
'contextPanel.mode.context': '上下文',
'contextPanel.mode.preview': '预览',
'contextPanel.mode.browser': '浏览器',
'contextRail.aria.rail': '面板界面',
'contextPanel.editorEmpty.title': '未打开文件',
'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。',
'contextRail.surface.editor.description': '编辑项目文件',
'contextRail.surface.git.description': '提交、分支和拉取请求',
'contextRail.surface.terminal.description': '内置终端',
'contextRail.surface.diff.description': '查看工作区更改',
'contextRail.surface.plan.description': '查看当前计划',
'contextRail.surface.pr.description': '创建、审查并合并当前分支的拉取请求',
'contextRail.surface.notes.description': '项目的笔记、待办和计划',
'contextRail.surface.context.description': '会话上下文与令牌用量',
'contextRail.surface.browser.description': '内置网页浏览器',
'contextRail.surface.preview.description': '开发服务器预览',
'contextRail.surface.chat.description': '并排打开的会话',
'contextRail.surface.notes': '项目笔记',
'contextRail.editorTree.toggle': '切换文件树',
'contextPanel.browser.open': '打开浏览器面板',
'contextPanel.browser.addressAria': '浏览器地址',
'contextPanel.browser.empty': '网页浏览器',
@@ -1096,6 +1121,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.actions.newFileTitle': '新建文件',
'sidebarFilesTree.actions.newFolderTitle': '新建文件夹',
'sidebarFilesTree.actions.refreshTitle': '刷新',
'sidebarFilesTree.actions.collapseAllTitle': '折叠所有文件夹',
'sidebarFilesTree.actions.fileMenuTitle': '文件菜单',
'sidebarFilesTree.state.searching': '搜索中...',
'sidebarFilesTree.state.loading': '加载中...',
@@ -1458,13 +1484,6 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.enterAria': 'Enter 键',
'terminalView.tabs.closeTabTitle': '关闭标签页',
'terminalView.tabs.newTabTitle': '新建标签页',
'terminalView.bottomDock.resizeAria': '调整终端面板大小',
'terminalView.bottomDock.restoreTitle': '恢复终端面板高度',
'terminalView.bottomDock.restoreAria': '恢复终端面板高度',
'terminalView.bottomDock.expandTitle': '展开终端面板',
'terminalView.bottomDock.expandAria': '展开终端面板',
'terminalView.bottomDock.closeTitle': '关闭终端面板',
'terminalView.bottomDock.closeAria': '关闭终端面板',
'terminalView.viewport.inputAria': '终端输入',
'directoryExplorerDialog.title': '添加项目目录',
'directoryExplorerDialog.description': '选择一个文件夹添加为项目。',
@@ -1542,10 +1561,9 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': '聚焦聊天输入框',
'helpDialog.item.togglePromptNavigator': '显示或隐藏提示词导航',
'helpDialog.item.abortActiveRun': '中止当前运行(双击)',
'helpDialog.item.toggleRightSidebar': '切换右侧边栏',
'helpDialog.item.openRightSidebarGitTab': '打开右侧边栏 Git 标签',
'helpDialog.item.openRightSidebarFilesTab': '打开右侧边栏文件标签',
'helpDialog.item.cycleRightSidebarTab': '循环右侧边栏标签',
'helpDialog.item.toggleRightSidebar': '切换上下文面板',
'helpDialog.item.openRightSidebarGitTab': '打开 Git 界面',
'helpDialog.item.openRightSidebarFilesTab': '打开文件界面',
'helpDialog.item.toggleTerminalDock': '切换终端停靠栏',
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
@@ -2162,7 +2180,6 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.addProject': '添加项目',
'commandPalette.item.showSessionSwitcher': '显示会话切换器',
'commandPalette.item.toggleSidebar': '切换侧边栏',
'commandPalette.item.toggleRightSidebar': '切换右侧边栏',
'commandPalette.item.showContextUsage': '显示上下文用量',
'commandPalette.item.toggleTerminal': '切换终端',
'commandPalette.item.openSettings': '打开设置...',
@@ -969,10 +969,9 @@
'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切換終端機停靠區',
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開',
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換右側邊欄',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟右側邊欄 Git 分頁',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟右側邊欄 Files 分頁',
'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '輪換右側邊欄分頁',
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
+39 -22
View File
@@ -8,6 +8,10 @@ export const dict: Record<I18nKey, string> = {
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.chatInput.terminalContextRemove': '移除終端上下文',
'chat.chatInput.prCommentContext': 'PR 留言',
'chat.chatInput.prCommentContextRemove': '移除 PR 留言脈絡',
'chat.chatInput.prCheckContext': '失敗的 PR 檢查',
'chat.chatInput.prCheckContextRemove': '移除 PR 檢查脈絡',
'common.loading': '載入中...',
'common.unavailable': '無法使用',
'common.language.english': 'English',
@@ -722,6 +726,8 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noIdentity': '無身分',
'gitView.header.noProfiles': '沒有可套用的設定。',
'gitView.header.repositoryViews': '儲存庫檢視',
'gitView.header.updateBranch': '更新分支',
'gitView.header.openPullRequest': '開啟提取請求',
'gitView.header.removeRemoteAria': '移除遠端 {name}',
'gitView.header.removeRemoteTitle': '移除 {name}',
'gitView.header.upstreamSynced': '已同步',
@@ -772,6 +778,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.history.renamedNoDiff': '已重新命名檔案 — 不支援 diff',
'gitView.history.renderDiffAnyway': '仍然渲染',
'gitView.history.title': '歷史紀錄',
'gitView.history.refresh': '重新整理',
'gitView.graph.title': '圖譜',
'gitView.integrate.checking': '檢查中…',
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
@@ -906,15 +913,15 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.actions.openOnGitHubAria': '在 GitHub 中開啟 Pull Request',
'gitView.pr.actions.openSettings': '開啟設定',
'gitView.pr.actions.repo': '儲存庫',
'gitView.pr.actions.resolveFailedChecks': '處理失敗檢查',
'gitView.pr.actions.resolveFailedChecksAria': '將失敗的檢查傳送給 Agent',
'gitView.pr.actions.resolveFailedChecks': '附加失敗檢查',
'gitView.pr.actions.resolveFailedChecksAria': '將失敗的檢查附加到聊天脈絡',
'gitView.pr.actions.savePr': '儲存 PR',
'gitView.pr.actions.savePrAria': '儲存 Pull Request 變更',
'gitView.pr.actions.sendCommentToAgent': '將留言傳送給 Agent',
'gitView.pr.actions.sendCommentToAgentAria': '將此留言傳送給 Agent',
'gitView.pr.actions.sendToAgent': '傳送給 Agent',
'gitView.pr.actions.shareComments': '分享留言',
'gitView.pr.actions.shareCommentsAria': '將 Pull Request 留言傳送給 Agent',
'gitView.pr.actions.sendCommentToAgent': '將留言附加到聊天脈絡',
'gitView.pr.actions.sendCommentToAgentAria': '將此留言附加到聊天脈絡',
'gitView.pr.actions.sendToAgent': '附加到聊天',
'gitView.pr.actions.shareComments': '附加留言',
'gitView.pr.actions.shareCommentsAria': '將提取請求留言附加到聊天脈絡',
'gitView.pr.actions.toggleDraftAria': '切換草稿狀態',
'gitView.pr.actions.refresh': '重新整理 PR 狀態',
'gitView.pr.actions.refreshAria': '重新整理 Pull Request 狀態',
@@ -952,6 +959,10 @@ export const dict: Record<I18nKey, string> = {
'gitView.pr.noMergePermission': '你沒有權限合併此 Pull Request。',
'gitView.pr.notMergeable': '不可合併',
'gitView.pr.numberLabel': 'PR #{number}',
'gitView.pr.segment.overview': '概覽',
'gitView.pr.segment.checks': '檢查',
'gitView.pr.segment.comments': '留言',
'gitView.pr.comments.addAll': '全部附加到聊天',
'gitView.pr.placeholder.additionalContext': '補充約束、發布說明或評審上下文...',
'gitView.pr.placeholder.description': '描述此變更...',
'gitView.pr.placeholder.main': '總結你的 Pull Request',
@@ -986,9 +997,6 @@ export const dict: Record<I18nKey, string> = {
'gitView.pullRequest.createHint': '從目前分支建立並管理 Pull Request。',
'gitView.pullRequest.availableOnFeatureBranches': '目前分支可以開啟 PR 時可用。',
'gitView.pullRequest.title': 'Pull Request',
'gitView.tabs.commit': '提交',
'gitView.tabs.pr': 'PR',
'gitView.tabs.update': '更新',
'gitView.tabs.worktree': 'Worktree',
'gitView.toast.abortOperationFailed': '中止操作失敗',
'gitView.toast.appliedIdentity': '已套用身分:{name}',
@@ -1055,9 +1063,26 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.mode.stagedDiff': 'Staged Diff',
'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': '計畫',
'contextPanel.mode.pr': '提取請求',
'contextPanel.mode.context': '上下文',
'contextPanel.mode.preview': '預覽',
'contextPanel.mode.browser': '瀏覽器',
'contextRail.aria.rail': '面板介面',
'contextPanel.editorEmpty.title': '未開啟檔案',
'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。',
'contextRail.surface.editor.description': '編輯專案檔案',
'contextRail.surface.git.description': '提交、分支與拉取請求',
'contextRail.surface.terminal.description': '內建終端機',
'contextRail.surface.diff.description': '檢視工作區變更',
'contextRail.surface.plan.description': '檢視目前計畫',
'contextRail.surface.pr.description': '建立、審查並合併目前分支的提取請求',
'contextRail.surface.notes.description': '專案的筆記、待辦與計畫',
'contextRail.surface.context.description': '工作階段情境與權杖用量',
'contextRail.surface.browser.description': '內建網頁瀏覽器',
'contextRail.surface.preview.description': '開發伺服器預覽',
'contextRail.surface.chat.description': '並排開啟的工作階段',
'contextRail.surface.notes': '專案筆記',
'contextRail.editorTree.toggle': '切換檔案樹',
'contextPanel.browser.open': '開啟瀏覽器面板',
'contextPanel.browser.addressAria': '瀏覽器網址',
'contextPanel.browser.empty': '網頁瀏覽器',
@@ -1108,6 +1133,7 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.actions.newFileTitle': '新增檔案',
'sidebarFilesTree.actions.newFolderTitle': '新增資料夾',
'sidebarFilesTree.actions.refreshTitle': '重新整理',
'sidebarFilesTree.actions.collapseAllTitle': '摺疊所有資料夾',
'sidebarFilesTree.state.searching': '搜尋中...',
'sidebarFilesTree.state.loading': '載入中...',
'sidebarFilesTree.dialog.createFile.title': '新增檔案',
@@ -1462,13 +1488,6 @@ export const dict: Record<I18nKey, string> = {
'terminalView.quickKeys.enterAria': 'Enter 鍵',
'terminalView.tabs.closeTabTitle': '關閉分頁',
'terminalView.tabs.newTabTitle': '新增分頁',
'terminalView.bottomDock.resizeAria': '調整終端機面板大小',
'terminalView.bottomDock.restoreTitle': '恢復終端機面板高度',
'terminalView.bottomDock.restoreAria': '恢復終端機面板高度',
'terminalView.bottomDock.expandTitle': '展開終端機面板',
'terminalView.bottomDock.expandAria': '展開終端機面板',
'terminalView.bottomDock.closeTitle': '關閉終端機面板',
'terminalView.bottomDock.closeAria': '關閉終端機面板',
'terminalView.viewport.inputAria': '終端機輸入',
'directoryExplorerDialog.title': '新增專案目錄',
'directoryExplorerDialog.description': '選擇一個資料夾新增為專案。',
@@ -1546,10 +1565,9 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.focusChatInput': '聚焦聊天輸入框',
'helpDialog.item.togglePromptNavigator': '顯示或隱藏提示詞導覽',
'helpDialog.item.abortActiveRun': '中止目前執行(連按兩下)',
'helpDialog.item.toggleRightSidebar': '切換右側邊欄',
'helpDialog.item.openRightSidebarGitTab': '開啟右側邊欄 Git 標籤',
'helpDialog.item.openRightSidebarFilesTab': '開啟右側邊欄檔案標籤',
'helpDialog.item.cycleRightSidebarTab': '循環右側邊欄標籤',
'helpDialog.item.toggleRightSidebar': '切換上下文面板',
'helpDialog.item.openRightSidebarGitTab': '開啟 Git 介面',
'helpDialog.item.openRightSidebarFilesTab': '開啟檔案介面',
'helpDialog.item.toggleTerminalDock': '切換終端機停靠欄',
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
@@ -2166,7 +2184,6 @@ export const dict: Record<I18nKey, string> = {
'commandPalette.item.addProject': '新增專案',
'commandPalette.item.showSessionSwitcher': '顯示會話切換器',
'commandPalette.item.toggleSidebar': '切換側邊欄',
'commandPalette.item.toggleRightSidebar': '切換右側邊欄',
'commandPalette.item.showContextUsage': '顯示上下文用量',
'commandPalette.item.toggleTerminal': '切換終端機',
'commandPalette.item.openSettings': '開啟設定...',
@@ -20,6 +20,14 @@ function formatInlineCommentDraft(draft: InlineCommentDraft): string {
if (draft.source === 'preview-annotation') {
return text ? `${code}\n\n${text}` : code;
}
if (draft.source === 'pr-comment') {
return `Attached GitHub PR comment (${fileLabel}):\n\n${code}${text ? `\n\n${text}` : ''}`;
}
if (draft.source === 'pr-check') {
return `Attached failed GitHub PR check (${fileLabel}):\n\`\`\`\n${code}\n\`\`\`${text ? `\n\n${text}` : ''}`;
}
// Plan and file format (no side)
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
-7
View File
@@ -200,13 +200,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
description: 'Open right sidebar and select Files',
customizable: true,
},
{
id: 'cycle_right_sidebar_tab',
defaultCombo: 'mod+shift+]',
label: 'Cycle right sidebar tab',
description: 'Cycle through right sidebar tabs',
customizable: true,
},
{
id: 'new_chat',
defaultCombo: 'mod+n',
@@ -0,0 +1,47 @@
# Context Surfaces
## Purpose
`packages/ui/src/lib/surfaces` owns the declarative registry of context panel
surfaces — the desktop workspaces switched by the vertical rail on the right
edge (`components/layout/ContextPanelRail.tsx`) and rendered by
`components/layout/ContextPanel.tsx`.
## Model
- A surface maps 1:1 to a `ContextPanelMode` tab mode in `useUIStore`.
- `availability: 'always'` surfaces are always present on the rail.
`availability: 'has-content'` surfaces (preview, chat) are hidden from the
rail until a tab of their mode exists, and stay visible for as long as one
does — they must not disappear while in use.
- `defaultWidthFraction` is the panel width as a fraction of the content area,
used until the user manually resizes that surface (manual widths are stored
per mode in `useUIStore.contextPanelByDirectory[dir].widthByMode`).
- Rail order is user-reorderable and persisted globally in
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
registry's default order and appends any missing surfaces.
## Adding a surface
1. Add a `ContextPanelMode` value in `useUIStore` (type union plus the
sanitizer whitelist in `sanitizeContextPanelTabs`).
2. Register a descriptor here (icon, label key, availability, width fraction).
3. Render the mode in `ContextPanel.tsx` (content dispatch, label, icon).
4. Add label/hint i18n keys to every locale dictionary.
No new header buttons: the rail and `openContextSurface` are the only entry
points for opening surfaces directly; deep links from chat/palette go through
the `openContext*` actions in `useUIStore`.
## Invariants
- Opening a surface must never require a control outside the rail, the
command palette, or an in-content link.
- Multi-instance and session-holding surfaces (file/editor, chat, diff,
browser, terminal) are keep-alive panes in `ContextPanel.tsx`: switching
surfaces must not reset their state (open tabs, xterm session, scroll
positions). Singleton surfaces (git, pr, notes, plan, context) and preview
tabs intentionally remount on switch and must restore themselves from
their stores/snapshots instead.
- Runtime scope: desktop/web `MainLayout` only. VS Code and the dedicated
mobile shell have their own layouts and do not consume this registry.
+179
View File
@@ -0,0 +1,179 @@
import type { IconName } from '@/components/icon/icons';
import type { I18nKey } from '@/lib/i18n';
import type { ContextPanelMode } from '@/stores/useUIStore';
export type ContextSurfaceId =
| 'editor'
| 'git'
| 'pr'
| 'diff'
| 'terminal'
| 'plan'
| 'notes'
| 'context'
| 'browser'
| 'preview'
| 'chat';
export type ContextSurfaceDescriptor = {
id: ContextSurfaceId;
/** The context panel tab mode this surface activates. 1:1 in the current model. */
mode: ContextPanelMode;
icon: IconName;
labelKey: I18nKey;
/**
* 'always' surfaces can be opened empty from the rail.
* 'has-content' surfaces are content-driven: they need an existing tab of
* their mode (a preview URL emitted, a split session) and stay hidden on
* the rail until one exists.
*/
availability: 'always' | 'has-content';
/** Short tooltip explanation shown on the rail. */
descriptionKey: I18nKey;
/**
* Default panel width as a fraction of the available content area, used
* until the user manually resizes this surface.
*/
defaultWidthFraction: number;
};
export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
{
id: 'context',
descriptionKey: 'contextRail.surface.context.description',
defaultWidthFraction: 0.45,
mode: 'context',
icon: 'donut-chart-fill',
labelKey: 'contextPanel.mode.context',
availability: 'always',
},
{
id: 'git',
descriptionKey: 'contextRail.surface.git.description',
defaultWidthFraction: 2 / 5,
mode: 'git',
icon: 'git-branch',
labelKey: 'layout.rightSidebar.git',
availability: 'always',
},
{
id: 'pr',
descriptionKey: 'contextRail.surface.pr.description',
defaultWidthFraction: 0.45,
mode: 'pr',
icon: 'git-pull-request',
labelKey: 'contextPanel.mode.pr',
availability: 'always',
},
{
id: 'diff',
descriptionKey: 'contextRail.surface.diff.description',
defaultWidthFraction: 3 / 5,
mode: 'diff',
icon: 'arrow-left-right',
labelKey: 'contextPanel.mode.diff',
availability: 'always',
},
{
id: 'editor',
descriptionKey: 'contextRail.surface.editor.description',
defaultWidthFraction: 3 / 5,
mode: 'file',
icon: 'file-code',
labelKey: 'contextPanel.mode.files',
availability: 'always',
},
{
id: 'terminal',
descriptionKey: 'contextRail.surface.terminal.description',
defaultWidthFraction: 3 / 5,
mode: 'terminal',
icon: 'terminal-box',
labelKey: 'layout.mainTab.terminal',
availability: 'always',
},
{
id: 'notes',
descriptionKey: 'contextRail.surface.notes.description',
defaultWidthFraction: 1 / 3,
mode: 'notes',
icon: 'sticky-note',
labelKey: 'contextRail.surface.notes',
availability: 'always',
},
{
id: 'plan',
descriptionKey: 'contextRail.surface.plan.description',
defaultWidthFraction: 0.45,
mode: 'plan',
icon: 'file-text',
labelKey: 'contextPanel.mode.plan',
availability: 'always',
},
{
id: 'browser',
descriptionKey: 'contextRail.surface.browser.description',
defaultWidthFraction: 0.45,
mode: 'browser',
icon: 'global',
labelKey: 'contextPanel.mode.browser',
availability: 'always',
},
{
id: 'preview',
descriptionKey: 'contextRail.surface.preview.description',
defaultWidthFraction: 0.45,
mode: 'preview',
icon: 'window',
labelKey: 'contextPanel.mode.preview',
availability: 'has-content',
},
{
id: 'chat',
descriptionKey: 'contextRail.surface.chat.description',
defaultWidthFraction: 0.45,
mode: 'chat',
icon: 'chat-4',
labelKey: 'contextPanel.mode.chat',
availability: 'has-content',
},
];
const SURFACE_BY_ID = new Map(CONTEXT_SURFACES.map((surface) => [surface.id, surface]));
const FRACTION_BY_MODE = new Map(CONTEXT_SURFACES.map((surface) => [surface.mode, surface.defaultWidthFraction]));
export const getContextSurfaceWidthFraction = (mode: ContextPanelMode): number => {
return FRACTION_BY_MODE.get(mode) ?? 1 / 2;
};
const isContextSurfaceId = (value: unknown): value is ContextSurfaceId => {
return typeof value === 'string' && SURFACE_BY_ID.has(value as ContextSurfaceId);
};
/**
* Applies a persisted user reorder on top of the default registry order:
* unknown ids are dropped, missing surfaces are appended in default order.
*/
export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfaceDescriptor[] => {
const ordered: ContextSurfaceDescriptor[] = [];
const seen = new Set<ContextSurfaceId>();
for (const id of railOrder) {
if (!isContextSurfaceId(id) || seen.has(id)) {
continue;
}
const surface = SURFACE_BY_ID.get(id);
if (surface) {
seen.add(id);
ordered.push(surface);
}
}
for (const surface of CONTEXT_SURFACES) {
if (!seen.has(surface.id)) {
ordered.push(surface);
}
}
return ordered;
};
+1 -1
View File
@@ -147,7 +147,7 @@ These rules are important. Breaking them tends to reintroduce idle CPU churn, st
4. Prefer store `ensure*` methods over direct runtime API calls from views.
5. Visible consumers should drive refresh. Hidden consumers should not.
6. Header should not depend on PR store.
7. Closed sidebar should not create live PR work.
7. A closed context panel (or hidden git surface) should not create live PR work.
8. File tree Git status should update only when the file tree is visible.
9. Global session refresh must remain bounded and failure-isolated per directory.
10. Global session cache must not drive live activity indicators or message-loading state.
@@ -25,6 +25,7 @@ type FilesViewTabsActions = {
setSelectedPath: (root: string, path: string | null, options?: { allowOutsideRoot?: boolean }) => void;
ensureSelectedPath: (root: string) => void;
toggleExpandedPath: (root: string, path: string) => void;
collapseAllExpandedPaths: (root: string) => void;
expandPath: (root: string, path: string) => void;
expandPaths: (root: string, paths: string[]) => void;
resetForRuntimeSwitch: (runtimeKey: string) => void;
@@ -417,6 +418,29 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
});
},
collapseAllExpandedPaths: (root) => {
const normalizedRoot = normalizePath((root || '').trim());
if (!normalizedRoot) {
return;
}
set((state) => {
const prev = state.byRoot[normalizedRoot];
if (!prev || prev.expandedPaths.length === 0) {
return state;
}
const byRoot = {
...state.byRoot,
[normalizedRoot]: {
...touchRoot(prev),
expandedPaths: [],
},
};
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
});
},
expandPath: (root, path) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPath = normalizePath((path || '').trim());
@@ -136,4 +136,33 @@ describe("GitHub PR status cache ownership", () => {
expect(staleRequestCount).toBe(0)
expect(replacementRequestCount).toBe(1)
})
test("rejects a server-cached response older than the held status", async () => {
const newer: GitHubPullRequestStatus = {
connected: true,
fetchedAt: 2_000,
pr: { number: 7, title: "t", url: "u", state: "open", draft: false, base: "main", head: "f" },
checks: { state: "pending", total: 3, success: 2, failure: 0, pending: 1 },
}
const older: GitHubPullRequestStatus = {
...newer,
fetchedAt: 1_000,
checks: { state: "success", total: 3, success: 3, failure: 0, pending: 0 },
}
const responses = [newer, older]
const github = { prStatus: async () => responses.shift()! } as unknown as RuntimeAPIs["github"]
const key = getGitHubPrStatusKey("/repo", "main", "origin")
useGitHubPrStatusStore.getState().ensureEntry(key)
useGitHubPrStatusStore.getState().setParams(key, params(github))
await useGitHubPrStatusStore.getState().refresh(key, { force: true })
expect(useGitHubPrStatusStore.getState().entries[key]?.status?.checks?.pending).toBe(1)
await useGitHubPrStatusStore.getState().refresh(key, { force: true })
const held = useGitHubPrStatusStore.getState().entries[key]?.status
expect(held?.fetchedAt).toBe(2_000)
expect(held?.checks?.pending).toBe(1)
expect(useGitHubPrStatusStore.getState().entries[key]?.isLoading).toBe(false)
})
})
@@ -179,6 +179,90 @@ const getSignatureFromEntry = (entry: PrStatusEntry | null | undefined): string
return JSON.stringify([identity.runtimeKey, identity.directory, identity.branch, identity.remoteName ?? 'auto']);
};
const parseStatusKey = (key: string): { runtimeKey: string; directory: string; branch: string; remote: string } | null => {
try {
const parsed: unknown = JSON.parse(key);
if (!Array.isArray(parsed) || parsed.length !== 4 || parsed.some((part) => typeof part !== 'string')) {
return null;
}
const [runtimeKey, directory, branch, remote] = parsed as [string, string, string, string];
return { runtimeKey, directory, branch, remote };
} catch {
return null;
}
};
/**
* Best already-resolved entry for the same directory+branch under a different
* remote key. Used to seed a freshly created entry so switching remote keys
* (e.g. 'auto' -> 'origin') shows the known PR immediately instead of a
* "checking status" flash; the entry's own refresh remains authoritative.
*/
const findResolvedSiblingEntry = (
entries: Record<string, PrStatusEntry>,
key: string,
): PrStatusEntry | null => {
const target = parseStatusKey(key);
if (!target) {
return null;
}
let fallback: PrStatusEntry | null = null;
for (const [entryKey, entry] of Object.entries(entries)) {
if (entryKey === key || !entry.isInitialStatusResolved || !entry.status) {
continue;
}
const parsed = parseStatusKey(entryKey);
if (!parsed
|| parsed.runtimeKey !== target.runtimeKey
|| parsed.directory !== target.directory
|| parsed.branch !== target.branch) {
continue;
}
const resolvedRemote = entry.resolvedRemoteName ?? entry.status.resolvedRemoteName ?? null;
if (target.remote !== 'auto' && resolvedRemote && resolvedRemote !== target.remote) {
continue;
}
if (parsed.remote === 'auto') {
return entry;
}
fallback = fallback ?? entry;
}
return fallback;
};
/**
* Freshest known status for a directory+branch across ALL remote-keyed
* entries. Passive readers (e.g. the git-view PR chip) should use this
* instead of a single key: the entry being actively watched/refreshed may be
* keyed by a concrete remote while the 'auto' entry goes stale.
*/
export const getFreshestPrStatusForBranch = (
entries: Record<string, PrStatusEntry>,
directory: string,
branch: string,
): GitHubPullRequestStatus | null => {
const runtimeKey = getRuntimeKey();
let best: PrStatusEntry | null = null;
for (const [key, entry] of Object.entries(entries)) {
if (!entry.status) {
continue;
}
const parsed = parseStatusKey(key);
if (!parsed
|| parsed.runtimeKey !== runtimeKey
|| parsed.directory !== directory
|| parsed.branch !== branch) {
continue;
}
if (!best || entry.lastRefreshAt > best.lastRefreshAt) {
best = entry;
}
}
return best?.status ?? null;
};
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
return Object.entries(entries)
.filter(([, entry]) => getSignatureFromEntry(entry) === signature)
@@ -331,10 +415,19 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
if (state.entries[key]) {
return state;
}
const sibling = findResolvedSiblingEntry(state.entries, key);
const seeded: PrStatusEntry = sibling
? {
...createEntry(),
status: sibling.status,
isInitialStatusResolved: true,
resolvedRemoteName: sibling.resolvedRemoteName ?? sibling.status?.resolvedRemoteName ?? null,
}
: createEntry();
return {
entries: boundEntries({
...state.entries,
[key]: createEntry(),
[key]: seeded,
}),
};
});
@@ -615,6 +708,25 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
return;
}
// Freshness guard: the server may serve this response from its
// cache. If we already hold newer data (e.g. checks derived
// from a fresher pulls/context fetch), keep it and only clear
// the loading flag — never regress to an older snapshot.
const currentFetchedAt = current.status?.fetchedAt;
const nextFetchedAt = next.fetchedAt;
if (typeof currentFetchedAt === 'number'
&& typeof nextFetchedAt === 'number'
&& nextFetchedAt < currentFetchedAt) {
nextEntries[signatureKey] = {
...current,
error: null,
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
lastRefreshAt: Date.now(),
};
return;
}
const prevPr = current.status?.pr;
const nextPr = next.pr;
const shouldCarryBody = Boolean(
@@ -108,7 +108,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
authType: data.sshCommand ? 'ssh' : undefined,
sshKey: data.sshCommand ? data.sshCommand.replace('ssh -i ', '') : null,
color: 'info',
icon: 'house'
icon: 'fingerprint'
};
set({ globalIdentity: globalProfile });
} else {
@@ -4,7 +4,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
import { normalizePath } from '@/lib/pathNormalization';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check';
export type InlineCommentDraftTarget = {
directory: string;
@@ -0,0 +1,95 @@
import { describe, expect, test } from 'bun:test';
import { usePrContextStore, getPrContextKey } from './usePrContextStore';
import type { GitHubAPI, GitHubPullRequestContextResult } from '@/lib/api/types';
const makeGithub = (result: GitHubPullRequestContextResult) => {
let calls = 0;
const prContext = async () => {
calls += 1;
return result;
};
return { github: { prContext } as unknown as GitHubAPI, callCount: () => calls };
};
const RESULT: GitHubPullRequestContextResult = {
connected: true,
pr: null,
issueComments: [],
reviewComments: [],
};
describe('usePrContextStore', () => {
test('caches within TTL and dedupes plain requests after a detailed one', async () => {
const { github, callCount } = makeGithub(RESULT);
const { ensure } = usePrContextStore.getState();
const first = await ensure(github, '/repo-a', 1, { includeCheckDetails: true });
expect(first).toEqual(RESULT);
expect(callCount()).toBe(1);
// Fresh cache satisfies both detailed and plain requests.
await ensure(github, '/repo-a', 1, { includeCheckDetails: true });
await ensure(github, '/repo-a', 1);
expect(callCount()).toBe(1);
// force bypasses the cache.
await ensure(github, '/repo-a', 1, { includeCheckDetails: true, force: true });
expect(callCount()).toBe(2);
});
test('a plain cached result does not satisfy a detailed request', async () => {
const { github, callCount } = makeGithub(RESULT);
const { ensure } = usePrContextStore.getState();
await ensure(github, '/repo-b', 2);
await ensure(github, '/repo-b', 2, { includeCheckDetails: true });
expect(callCount()).toBe(2);
});
test('concurrent callers share one in-flight request', async () => {
const { github, callCount } = makeGithub(RESULT);
const { ensure } = usePrContextStore.getState();
const [a, b] = await Promise.all([
ensure(github, '/repo-c', 3, { includeCheckDetails: true }),
ensure(github, '/repo-c', 3, { includeCheckDetails: true }),
]);
expect(a).toEqual(RESULT);
expect(b).toEqual(RESULT);
expect(callCount()).toBe(1);
});
test('invalidate clears the entry so the next ensure refetches', async () => {
const { github, callCount } = makeGithub(RESULT);
const { ensure, invalidate } = usePrContextStore.getState();
await ensure(github, '/repo-d', 4);
invalidate('/repo-d', 4);
expect(usePrContextStore.getState().entries[getPrContextKey('/repo-d', 4)] ?? null).toBe(null);
await ensure(github, '/repo-d', 4);
expect(callCount()).toBe(2);
});
test('invalidate matches the directory exactly, not by prefix', async () => {
const { github } = makeGithub(RESULT);
const { ensure, invalidate } = usePrContextStore.getState();
await ensure(github, '/repo', 1);
await ensure(github, '/repo-beta', 1);
invalidate('/repo');
expect(usePrContextStore.getState().entries[getPrContextKey('/repo', 1)] ?? null).toBe(null);
expect(usePrContextStore.getState().entries[getPrContextKey('/repo-beta', 1)]?.result).toEqual(RESULT);
});
test('failed fetch records the error and returns null', async () => {
const github = { prContext: async () => { throw new Error('boom'); } } as unknown as GitHubAPI;
const { ensure } = usePrContextStore.getState();
const result = await ensure(github, '/repo-e', 5);
expect(result).toBeNull();
const entry = usePrContextStore.getState().entries[getPrContextKey('/repo-e', 5)];
expect(entry?.error).toBe('boom');
expect(entry?.isLoading).toBe(false);
});
});
+169
View File
@@ -0,0 +1,169 @@
import { create } from 'zustand';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type {
GitHubAPI,
GitHubPullRequestContextResult,
GitHubRepoSelector,
} from '@/lib/api/types';
const PR_CONTEXT_TTL_MS = 30_000;
const PR_CONTEXT_MAX_ENTRIES = 20;
// JSON tuple key: runtime-scoped (a runtime switch must never serve another
// backend's data) and parseable, so invalidation can compare the directory
// exactly instead of by string prefix.
export const getPrContextKey = (directory: string, number: number): string =>
JSON.stringify([getRuntimeKey(), directory, number]);
const parsePrContextKey = (key: string): { runtimeKey: string; directory: string; number: number } | null => {
try {
const parsed: unknown = JSON.parse(key);
if (!Array.isArray(parsed) || parsed.length !== 3) {
return null;
}
const [runtimeKey, directory, number] = parsed;
if (typeof runtimeKey !== 'string' || typeof directory !== 'string' || typeof number !== 'number') {
return null;
}
return { runtimeKey, directory, number };
} catch {
return null;
}
};
type PrContextEntry = {
result: GitHubPullRequestContextResult | null;
/** Whether `result` was fetched with check details included. */
hasCheckDetails: boolean;
fetchedAt: number;
isLoading: boolean;
error: string | null;
};
type EnsureOptions = {
includeCheckDetails?: boolean;
sourceRepo?: GitHubRepoSelector | null;
force?: boolean;
};
interface PrContextStoreState {
entries: Record<string, PrContextEntry>;
/**
* Fetch (or reuse) the PR context for directory#number. Deduplicates
* concurrent callers and serves a fresh-enough cached result; a
* details-inclusive result satisfies detail-free requests.
*/
ensure: (
github: GitHubAPI,
directory: string,
number: number,
options?: EnsureOptions,
) => Promise<GitHubPullRequestContextResult | null>;
invalidate: (directory: string, number?: number) => void;
}
const createEntry = (): PrContextEntry => ({
result: null,
hasCheckDetails: false,
fetchedAt: 0,
isLoading: false,
error: null,
});
const inFlight = new Map<string, Promise<GitHubPullRequestContextResult | null>>();
const boundEntries = (entries: Record<string, PrContextEntry>): Record<string, PrContextEntry> => {
const all = Object.entries(entries);
if (all.length <= PR_CONTEXT_MAX_ENTRIES) return entries;
return Object.fromEntries(all
.sort(([, left], [, right]) => right.fetchedAt - left.fetchedAt)
.slice(0, PR_CONTEXT_MAX_ENTRIES));
};
export const usePrContextStore = create<PrContextStoreState>()((set, get) => ({
entries: {},
ensure: async (github, directory, number, options) => {
const key = getPrContextKey(directory, number);
const includeCheckDetails = options?.includeCheckDetails ?? false;
const existing = get().entries[key];
const isFresh = existing
&& existing.result
&& Date.now() - existing.fetchedAt < PR_CONTEXT_TTL_MS
&& (existing.hasCheckDetails || !includeCheckDetails);
if (!options?.force && isFresh) {
return existing.result;
}
// A detail-inclusive request in flight satisfies everyone; a detail-free
// one only satisfies detail-free callers.
const inFlightKey = `${key}::${includeCheckDetails ? 'details' : 'plain'}`;
const detailedInFlight = inFlight.get(`${key}::details`);
const pending = detailedInFlight ?? (includeCheckDetails ? null : inFlight.get(inFlightKey));
if (pending && !options?.force) {
return pending;
}
const request = (async (): Promise<GitHubPullRequestContextResult | null> => {
set((state) => ({
entries: {
...state.entries,
[key]: { ...(state.entries[key] ?? createEntry()), isLoading: true, error: null },
},
}));
try {
const result = await github.prContext(directory, number, {
includeDiff: false,
includeCheckDetails,
sourceRepo: options?.sourceRepo ?? null,
});
set((state) => ({
entries: boundEntries({
...state.entries,
[key]: {
result,
hasCheckDetails: includeCheckDetails,
fetchedAt: Date.now(),
isLoading: false,
error: null,
},
}),
}));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load pull request context';
set((state) => ({
entries: {
...state.entries,
[key]: { ...(state.entries[key] ?? createEntry()), isLoading: false, error: message },
},
}));
return null;
} finally {
inFlight.delete(inFlightKey);
}
})();
inFlight.set(inFlightKey, request);
return request;
},
invalidate: (directory, number) => {
set((state) => {
const next: Record<string, PrContextEntry> = {};
for (const [key, entry] of Object.entries(state.entries)) {
const parsed = parsePrContextKey(key);
const matches = Boolean(
parsed
&& parsed.directory === directory
&& (number == null || parsed.number === number),
);
if (!matches) {
next[key] = entry;
}
}
return { entries: next };
});
},
}));
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { CONTEXT_SURFACES, sortContextSurfaces } from '../lib/surfaces/registry';
import { useUIStore } from './useUIStore';
beforeEach(() => {
useUIStore.setState({ contextPanelByDirectory: {} });
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
});
describe('useUIStore context panel tabs', () => {
@@ -28,3 +29,149 @@ describe('useUIStore context panel tabs', () => {
expect(tabs[0]?.readOnly).toBe(false);
});
});
describe('useUIStore openContextSurface', () => {
const directory = '/repo';
test('opens a fresh singleton tab when none of that mode exists', () => {
useUIStore.getState().openContextSurface(directory, 'diff');
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.isOpen).toBe(true);
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['diff']);
});
test('activates the existing tab of the requested mode instead of duplicating it', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
useUIStore.getState().openContextPanelTab(directory, { mode: 'file', targetPath: '/repo/a.ts' });
useUIStore.getState().openContextSurface(directory, 'diff');
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs.filter((tab) => tab.mode === 'diff')).toHaveLength(1);
expect(state?.activeTabId).toBe('diff');
expect(state?.isOpen).toBe(true);
});
test('toggles the panel closed when the requested mode is already active and open', () => {
useUIStore.getState().openContextSurface(directory, 'diff');
useUIStore.getState().openContextSurface(directory, 'diff');
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.isOpen).toBe(false);
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['diff']);
});
test('does nothing for content-driven modes without existing content', () => {
useUIStore.getState().openContextSurface(directory, 'preview');
useUIStore.getState().openContextSurface(directory, 'chat');
expect(useUIStore.getState().contextPanelByDirectory[directory]).toBe(undefined);
});
test('opens an empty editor tab that a real file later replaces', () => {
useUIStore.getState().openContextSurface(directory, 'file');
let state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.isOpen).toBe(true);
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['file']);
expect(state?.tabs[0]?.targetPath).toBe(null);
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs.filter((tab) => tab.mode === 'file')).toHaveLength(1);
expect(state?.tabs.find((tab) => tab.mode === 'file')?.targetPath).toBe('/repo/a.ts');
});
test('activates the most recently touched tab of a content-driven mode', () => {
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
useUIStore.getState().openContextSurface(directory, 'file');
const state = useUIStore.getState().contextPanelByDirectory[directory];
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
expect(activeTab?.mode).toBe('file');
expect(activeTab?.targetPath).toBe('/repo/b.ts');
});
});
describe('useUIStore closeContextPanelTab surface stability', () => {
const directory = '/repo';
test('closing an active file tab activates another file tab, not another surface', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
const stateBefore = useUIStore.getState().contextPanelByDirectory[directory];
const activeTabId = stateBefore?.activeTabId as string;
useUIStore.getState().closeContextPanelTab(directory, activeTabId);
const state = useUIStore.getState().contextPanelByDirectory[directory];
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
expect(activeTab?.mode).toBe('file');
expect(activeTab?.targetPath).toBe('/repo/a.ts');
expect(state?.isOpen).toBe(true);
});
test('closing the last tab of the active surface closes the panel', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
const stateBefore = useUIStore.getState().contextPanelByDirectory[directory];
useUIStore.getState().closeContextPanelTab(directory, stateBefore?.activeTabId as string);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.isOpen).toBe(false);
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['terminal']);
});
test('closing an inactive tab keeps the active tab untouched', () => {
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTab = state0?.tabs.find((tab) => tab.mode === 'file');
useUIStore.getState().closeContextPanelTab(directory, fileTab?.id as string);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.activeTabId).toBe('terminal');
expect(state?.isOpen).toBe(true);
});
});
describe('useUIStore per-surface panel widths', () => {
const directory = '/repo';
test('setContextPanelWidth stores a clamped manual width for one mode only', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
useUIStore.getState().setContextPanelWidth(directory, 'diff', 700);
useUIStore.getState().setContextPanelWidth(directory, 'git', 100);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.widthByMode.diff).toBe(700);
expect(state?.widthByMode.git).toBe(380);
expect(state?.widthByMode.browser).toBe(undefined);
});
});
describe('useUIStore contextRailOrder', () => {
test('setContextRailOrder drops empty and duplicate ids', () => {
useUIStore.getState().setContextRailOrder(['diff', 'diff', '', 'editor']);
expect(useUIStore.getState().contextRailOrder).toEqual(['diff', 'editor']);
});
test('sortContextSurfaces applies persisted order and appends missing surfaces', () => {
const ordered = sortContextSurfaces(['browser', 'unknown-id', 'diff']);
const ids = ordered.map((surface) => surface.id);
expect(ids.slice(0, 2)).toEqual(['browser', 'diff']);
// Assert against the registry itself so this test cannot go stale when a
// surface is added or removed.
expect(new Set(ids)).toEqual(new Set(CONTEXT_SURFACES.map((surface) => surface.id)));
expect(ids).toHaveLength(CONTEXT_SURFACES.length);
});
});
+142 -174
View File
@@ -9,11 +9,11 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type { TerminalShell } from '@/lib/api/types';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type RightSidebarTab = 'git' | 'files' | 'context';
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -57,7 +57,9 @@ type ContextPanelDirectoryState = {
expanded: boolean;
tabs: ContextPanelTab[];
activeTabId: string | null;
width: number;
// Manual per-surface widths (px), populated only by user resize; surfaces
// without an entry fall back to their registry defaultWidthFraction.
widthByMode: Partial<Record<ContextPanelMode, number>>;
touchedAt: number;
};
@@ -118,8 +120,6 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_MAX_TABS = 12;
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
const LEFT_SIDEBAR_MIN_WIDTH = 280;
export const RIGHT_SIDEBAR_MIN_WIDTH = 360;
export const RIGHT_SIDEBAR_MAX_WIDTH = 860;
const activeMainTabByRuntime = new Map<string, MainTab>();
const runtimeMemoryKey = (value?: string | null): string => {
@@ -127,6 +127,9 @@ const runtimeMemoryKey = (value?: string | null): string => {
return key || 'default';
};
// Shared with rail/panel consumers so contextPanelByDirectory lookups agree on keys.
export const normalizeContextPanelDirectoryKey = (value: string): string => normalizeDirectoryPath(value);
const normalizeDirectoryPath = (value: string): string => {
if (!value) return '';
@@ -283,7 +286,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
touchedAt?: unknown;
};
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser') {
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
continue;
}
@@ -347,7 +350,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
expanded: false,
tabs: [],
activeTabId: null,
width: CONTEXT_PANEL_DEFAULT_WIDTH,
widthByMode: {},
touchedAt: Date.now(),
};
};
@@ -357,10 +360,15 @@ const upsertContextPanelTab = (
descriptor: ContextPanelTabDescriptor,
): ContextPanelDirectoryState => {
const nextTab = createContextPanelTab(descriptor);
const existingIndex = current.tabs.findIndex((tab) => tab.id === nextTab.id);
// A real file tab replaces the empty editor placeholder ('file' with no
// target) that the rail can open before any file is picked.
const baseTabs = nextTab.mode === 'file' && nextTab.targetPath
? current.tabs.filter((tab) => !(tab.mode === 'file' && !tab.targetPath))
: current.tabs;
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
const tabs = existingIndex === -1
? [...current.tabs, nextTab]
: current.tabs.map((tab, index) => (index === existingIndex
? [...baseTabs, nextTab]
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
@@ -391,16 +399,32 @@ const closeContextPanelTab = (
current: ContextPanelDirectoryState,
tabID: string,
): ContextPanelDirectoryState => {
const closedTab = current.tabs.find((tab) => tab.id === tabID) ?? null;
const nextTabs = current.tabs.filter((tab) => tab.id !== tabID);
const nextActiveTabId = current.activeTabId === tabID
? (nextTabs[nextTabs.length - 1]?.id ?? null)
: resolveActiveContextPanelTabID(nextTabs, current.activeTabId);
if (current.activeTabId !== tabID) {
return {
...current,
tabs: nextTabs,
activeTabId: resolveActiveContextPanelTabID(nextTabs, current.activeTabId),
isOpen: nextTabs.length > 0 ? current.isOpen : false,
touchedAt: Date.now(),
};
}
// Closing the active tab stays inside the active surface: activate the most
// recent remaining tab of the same mode, and when it was the last one just
// close the panel instead of jumping to another surface.
const sameModeTabs = closedTab ? nextTabs.filter((tab) => tab.mode === closedTab.mode) : [];
const nextSameModeTab = sameModeTabs.length > 0
? sameModeTabs.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best))
: null;
return {
...current,
tabs: nextTabs,
activeTabId: nextActiveTabId,
isOpen: nextTabs.length > 0 ? current.isOpen : false,
activeTabId: nextSameModeTab?.id ?? resolveActiveContextPanelTabID(nextTabs, null),
isOpen: nextSameModeTab ? current.isOpen : false,
touchedAt: Date.now(),
};
};
@@ -467,7 +491,7 @@ const sanitizeContextPanelByDirectory = (
expanded?: unknown;
tabs?: unknown;
activeTabId?: unknown;
width?: unknown;
widthByMode?: unknown;
touchedAt?: unknown;
mode?: unknown;
targetPath?: unknown;
@@ -491,12 +515,27 @@ const sanitizeContextPanelByDirectory = (
const resolvedActiveTabId = resolveActiveContextPanelTabID(tabs, activeTabId);
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, resolvedActiveTabId);
// Legacy single `width` values are intentionally dropped: widths are now
// per-surface, seeded from registry defaults until the user resizes.
const widthByMode: Partial<Record<ContextPanelMode, number>> = {};
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
if (
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'preview' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal')
&& typeof value === 'number'
&& Number.isFinite(value)
) {
widthByMode[mode] = clampContextPanelWidth(value);
}
}
}
next[directory] = {
isOpen: candidate.isOpen === true,
expanded: candidate.expanded === true,
tabs: clampedTabs,
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
width: clampContextPanelWidth(typeof candidate.width === 'number' ? candidate.width : CONTEXT_PANEL_DEFAULT_WIDTH),
widthByMode,
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt
: Date.now(),
@@ -531,15 +570,10 @@ interface UIStore {
isSidebarOpen: boolean;
sidebarWidth: number;
hasManuallyResizedLeftSidebar: boolean;
isRightSidebarOpen: boolean;
rightSidebarWidth: number;
hasManuallyResizedRightSidebar: boolean;
rightSidebarTab: RightSidebarTab;
contextPanelByDirectory: Record<string, ContextPanelDirectoryState>;
isBottomTerminalOpen: boolean;
isBottomTerminalExpanded: boolean;
bottomTerminalHeight: number;
hasManuallyResizedBottomTerminal: boolean;
contextRailOrder: string[];
contextEditorTreeVisible: boolean;
contextEditorTreeWidth: number;
notesPanelHeight: number;
todoPanelHeight: number;
isSessionSwitcherOpen: boolean;
@@ -677,10 +711,10 @@ interface UIStore {
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarWidth: (width: number) => void;
toggleRightSidebar: () => void;
setRightSidebarOpen: (open: boolean) => void;
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
setContextRailOrder: (order: string[]) => void;
toggleContextEditorTree: () => void;
setContextEditorTreeWidth: (width: number) => void;
openContextSurface: (directory: string, mode: ContextPanelMode) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
openContextFile: (directory: string, filePath: string) => void;
@@ -695,11 +729,7 @@ interface UIStore {
closeContextPanelTab: (directory: string, tabID: string) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, width: number) => void;
toggleBottomTerminal: () => void;
setBottomTerminalOpen: (open: boolean) => void;
setBottomTerminalExpanded: (expanded: boolean) => void;
setBottomTerminalHeight: (height: number) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
setNotesPanelHeight: (height: number) => void;
setTodoPanelHeight: (height: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
@@ -765,7 +795,6 @@ interface UIStore {
setMobileKeyboardMode: (mode: MobileKeyboardMode) => void;
applyTypography: () => void;
applyPadding: () => void;
updateProportionalSidebarWidths: () => void;
toggleFavoriteModel: (providerID: string, modelID: string) => void;
reorderFavoriteModel: (
activeProviderID: string,
@@ -855,15 +884,10 @@ export const useUIStore = create<UIStore>()(
isSidebarOpen: true,
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
hasManuallyResizedLeftSidebar: false,
isRightSidebarOpen: false,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
hasManuallyResizedRightSidebar: false,
rightSidebarTab: 'git',
contextPanelByDirectory: {},
isBottomTerminalOpen: false,
isBottomTerminalExpanded: false,
bottomTerminalHeight: 300,
hasManuallyResizedBottomTerminal: false,
contextRailOrder: [],
contextEditorTreeVisible: true,
contextEditorTreeWidth: 240,
notesPanelHeight: 112,
todoPanelHeight: 259,
isSessionSwitcherOpen: false,
@@ -1036,45 +1060,60 @@ export const useUIStore = create<UIStore>()(
set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true });
},
toggleRightSidebar: () => {
set((state) => {
const newOpen = !state.isRightSidebarOpen;
if (newOpen && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: newOpen,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return { isRightSidebarOpen: newOpen };
});
setContextRailOrder: (order) => {
const sanitized = Array.isArray(order)
? order.filter((id, index) => typeof id === 'string' && id.trim() !== '' && order.indexOf(id) === index)
: [];
set({ contextRailOrder: sanitized });
},
setRightSidebarOpen: (open) => {
set((state) => {
if (state.isRightSidebarOpen === open) {
return state;
}
const shouldResetWidth = open
&& !state.hasManuallyResizedRightSidebar
&& state.rightSidebarWidth !== RIGHT_SIDEBAR_MIN_WIDTH;
return {
isRightSidebarOpen: open,
rightSidebarWidth: shouldResetWidth ? RIGHT_SIDEBAR_MIN_WIDTH : state.rightSidebarWidth,
};
});
toggleContextEditorTree: () => {
set((state) => ({ contextEditorTreeVisible: !state.contextEditorTreeVisible }));
},
setRightSidebarWidth: (width) => {
const clamped = Math.min(
RIGHT_SIDEBAR_MAX_WIDTH,
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, width)
);
set({ rightSidebarWidth: clamped, hasManuallyResizedRightSidebar: true });
setContextEditorTreeWidth: (width) => {
if (!Number.isFinite(width)) {
return;
}
set({ contextEditorTreeWidth: Math.min(480, Math.max(200, Math.round(width))) });
},
setRightSidebarTab: (tab) => {
set({ rightSidebarTab: tab });
// Rail entry point: activates the most recent tab of the requested
// mode, opens a fresh singleton tab when none exists, and toggles the
// panel closed when the requested mode is already active and visible.
openContextSurface: (directory, mode) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
const state = get();
const panelState = state.contextPanelByDirectory[normalizedDirectory];
const tabs = panelState?.tabs ?? [];
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? null;
if (panelState?.isOpen && activeTab?.mode === mode) {
state.closeContextPanel(normalizedDirectory);
return;
}
const tabsOfMode = tabs.filter((tab) => tab.mode === mode);
if (tabsOfMode.length > 0) {
// `>=` so equal timestamps (same-millisecond opens) resolve to the
// later tab in insertion order.
const mostRecent = tabsOfMode.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best));
state.setActiveContextPanelTab(normalizedDirectory, mostRecent.id);
return;
}
// Content-driven modes need a payload (a preview URL or session);
// the rail renders them disabled until content exists. 'file' opens
// an empty editor whose embedded tree picks the first file.
if (mode === 'preview' || mode === 'chat') {
return;
}
state.openContextPanelTab(normalizedDirectory, { mode });
},
openContextPanelTab: (directory, tab) => {
@@ -1283,6 +1322,9 @@ export const useUIStore = create<UIStore>()(
return;
}
const closingTab = get().contextPanelByDirectory[normalizedDirectory]?.tabs
.find((tab) => tab.id === normalizedTabID);
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
@@ -1297,6 +1339,12 @@ export const useUIStore = create<UIStore>()(
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
// Keep the editor's own open-file state in sync so a reopened
// editor surface does not resurrect the closed file.
if (closingTab?.mode === 'file' && closingTab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, closingTab.targetPath);
}
},
closeContextPanel: (directory) => {
@@ -1344,7 +1392,7 @@ export const useUIStore = create<UIStore>()(
});
},
setContextPanelWidth: (directory, width) => {
setContextPanelWidth: (directory, mode, width) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
@@ -1357,7 +1405,10 @@ export const useUIStore = create<UIStore>()(
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
width: clampContextPanelWidth(width),
widthByMode: {
...current.widthByMode,
[mode]: clampContextPanelWidth(width),
},
},
};
@@ -1365,64 +1416,6 @@ export const useUIStore = create<UIStore>()(
});
},
toggleBottomTerminal: () => {
set((state) => {
const newOpen = !state.isBottomTerminalOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
return {
isBottomTerminalOpen: newOpen,
bottomTerminalHeight: proportionalHeight,
hasManuallyResizedBottomTerminal: false,
};
}
return { isBottomTerminalOpen: newOpen };
});
},
setBottomTerminalOpen: (open) => {
set((state) => {
if (state.isBottomTerminalOpen === open) {
if (!open) {
return state;
}
if (!state.hasManuallyResizedBottomTerminal && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
if (state.bottomTerminalHeight === proportionalHeight && state.hasManuallyResizedBottomTerminal === false) {
return state;
}
return {
isBottomTerminalOpen: open,
bottomTerminalHeight: proportionalHeight,
hasManuallyResizedBottomTerminal: false,
};
}
return state;
}
if (open && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
return {
isBottomTerminalOpen: open,
bottomTerminalHeight: proportionalHeight,
hasManuallyResizedBottomTerminal: false,
};
}
return { isBottomTerminalOpen: open };
});
},
setBottomTerminalExpanded: (expanded) => {
set({ isBottomTerminalExpanded: expanded });
},
setBottomTerminalHeight: (height) => {
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
},
setNotesPanelHeight: (height) => {
set({ notesPanelHeight: height });
},
@@ -2009,25 +2002,6 @@ export const useUIStore = create<UIStore>()(
});
},
updateProportionalSidebarWidths: () => {
if (typeof window === 'undefined') {
return;
}
set((state) => {
const updates: Partial<UIStore> = {};
if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) {
const nextHeight = Math.floor(window.innerHeight * 0.32);
if (state.bottomTerminalHeight !== nextHeight) {
updates.bottomTerminalHeight = nextHeight;
}
}
return Object.keys(updates).length > 0 ? updates : state;
});
},
applyTheme: () => {
const { theme } = get();
const root = document.documentElement;
@@ -2189,13 +2163,7 @@ export const useUIStore = create<UIStore>()(
viewPagerPage: 'center',
setViewPagerPage: (page: 'left' | 'center' | 'right') => {
set({ viewPagerPage: page });
if (page === 'left') {
set({ isSessionSwitcherOpen: true, isRightSidebarOpen: false });
} else if (page === 'right') {
set({ isRightSidebarOpen: true, isSessionSwitcherOpen: false });
} else {
set({ isSessionSwitcherOpen: false, isRightSidebarOpen: false });
}
set({ isSessionSwitcherOpen: page === 'left' });
},
setShortcutOverride: (actionId, combo) => {
@@ -2301,12 +2269,11 @@ export const useUIStore = create<UIStore>()(
delete state.memoryLimitActiveSession;
}
if (
typeof state.rightSidebarTab !== 'string'
|| (state.rightSidebarTab !== 'git' && state.rightSidebarTab !== 'files' && state.rightSidebarTab !== 'context')
) {
state.rightSidebarTab = 'git';
}
// Right-sidebar state was removed with the sidebar itself; drop
// stale persisted fields.
delete state.isRightSidebarOpen;
delete state.rightSidebarWidth;
delete state.rightSidebarTab;
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
@@ -2341,19 +2308,20 @@ export const useUIStore = create<UIStore>()(
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
state.contextRailOrder = Array.isArray(state.contextRailOrder)
? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '')
: [];
return state;
},
partialize: (state) => ({
theme: state.theme,
isSidebarOpen: state.isSidebarOpen,
sidebarWidth: state.sidebarWidth,
isRightSidebarOpen: state.isRightSidebarOpen,
rightSidebarWidth: state.rightSidebarWidth,
rightSidebarTab: state.rightSidebarTab,
contextPanelByDirectory: state.contextPanelByDirectory,
isBottomTerminalOpen: state.isBottomTerminalOpen,
isBottomTerminalExpanded: state.isBottomTerminalExpanded,
bottomTerminalHeight: state.bottomTerminalHeight,
contextRailOrder: state.contextRailOrder,
contextEditorTreeVisible: state.contextEditorTreeVisible,
contextEditorTreeWidth: state.contextEditorTreeWidth,
notesPanelHeight: state.notesPanelHeight,
todoPanelHeight: state.todoPanelHeight,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,