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

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