Merge main
This commit is contained in:
@@ -22,6 +22,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device';
|
||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -111,7 +112,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
|
||||
// A plan opened from the workspace drawer's Notes tab, shown as a fullscreen
|
||||
// layer on top of it (back returns to the notes).
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string; projectRef: ProjectRef } | null>(null);
|
||||
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
||||
// When set, the Changes surface opens directly into the per-file diff for this path.
|
||||
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
|
||||
@@ -542,7 +543,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<PlanView
|
||||
projectPlanId={openPlan.id}
|
||||
savedProjectPlan={{ projectRef: openPlan.projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => {
|
||||
closeSurface();
|
||||
closeWorkspace();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
@@ -105,7 +106,7 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
/** When set, the Changes tab opens directly into the per-file diff. */
|
||||
pendingChangesDiff: { path: string; staged: boolean } | null;
|
||||
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
|
||||
onOpenPlan: (plan: { id: string; title: string }) => void;
|
||||
onOpenPlan: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
variant?: 'drawer' | 'panel';
|
||||
|
||||
@@ -1010,6 +1010,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (!providerIdToSend || !modelIdToSend) {
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
toast.error(t('chat.chatInput.toast.noModelSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -702,40 +702,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
const shellOutputs = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const output = part.shellAction?.output;
|
||||
return typeof output === 'string' ? output.trim() : '';
|
||||
})
|
||||
.filter((output) => output.length > 0);
|
||||
|
||||
if (shellOutputs.length > 0) {
|
||||
return shellOutputs.join('\n\n');
|
||||
}
|
||||
|
||||
const shellCommands = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const command = part.shellAction?.command;
|
||||
return typeof command === 'string' ? command.trim() : '';
|
||||
})
|
||||
.filter((command) => command.length > 0);
|
||||
|
||||
if (shellCommands.length > 0) {
|
||||
return shellCommands.join('\n');
|
||||
}
|
||||
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
return flattenUserTextParts(displayParts);
|
||||
}
|
||||
|
||||
if (assistantErrorText && assistantErrorText.trim().length > 0) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -66,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
||||
const hasSession = Boolean(currentSessionId);
|
||||
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const canStartSessionCommand = hasSession || hasNewSessionDraft;
|
||||
@@ -140,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}));
|
||||
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -200,10 +197,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: allCommands;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||
@@ -216,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
setCommands(filtered);
|
||||
} catch {
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -277,12 +272,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
),
|
||||
];
|
||||
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? builtInCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: builtInCommands;
|
||||
|
||||
setCommands(filtered);
|
||||
} finally {
|
||||
@@ -291,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
};
|
||||
|
||||
loadCommands();
|
||||
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
|
||||
@@ -20,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
padding: '0',
|
||||
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
|
||||
paddingInlineStart: '1px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
|
||||
@@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
row.setAttribute('data-md-code-line', '');
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.setAttribute('data-md-code-line-number', '');
|
||||
number.setAttribute('data-md-code-line-number', String(index + 1));
|
||||
number.setAttribute('aria-hidden', 'true');
|
||||
number.textContent = String(index + 1);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.setAttribute('data-md-code-line-content', '');
|
||||
@@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
} else {
|
||||
content.textContent = sourceLine;
|
||||
}
|
||||
|
||||
row.append(number, content);
|
||||
fragment.appendChild(row);
|
||||
if (index < sourceLines.length - 1 || hasTrailingNewline) {
|
||||
@@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
|
||||
};
|
||||
|
||||
const getMarkdownCodeSelectionText = (range: Range): string | null => {
|
||||
const code = getContainingMarkdownCode(range.startContainer);
|
||||
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
|
||||
// Line numbers are CSS-generated, so the DOM range is already the exact
|
||||
// source selection, including boundaries between rows and empty lines.
|
||||
return range.toString();
|
||||
};
|
||||
|
||||
type MarkdownCopyState = {
|
||||
registrations: number;
|
||||
handler: (event: ClipboardEvent) => void;
|
||||
menuHandler: (event: Event) => void;
|
||||
};
|
||||
|
||||
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
|
||||
|
||||
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
|
||||
let state = markdownCopyStates.get(doc);
|
||||
if (!state) {
|
||||
const getSelectedText = (): string | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
|
||||
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
|
||||
};
|
||||
const handler = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) return;
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
};
|
||||
const menuHandler = (event: Event) => {
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
void copyTextToClipboard(text);
|
||||
};
|
||||
state = { registrations: 0, handler, menuHandler };
|
||||
markdownCopyStates.set(doc, state);
|
||||
doc.addEventListener('copy', handler, true);
|
||||
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
|
||||
}
|
||||
state.registrations += 1;
|
||||
|
||||
return () => {
|
||||
const current = markdownCopyStates.get(doc);
|
||||
if (!current) return;
|
||||
current.registrations -= 1;
|
||||
if (current.registrations > 0) return;
|
||||
doc.removeEventListener('copy', current.handler, true);
|
||||
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
|
||||
markdownCopyStates.delete(doc);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a single delegated click listener for all in-markdown actions: code
|
||||
* copy, table copy/download menus, mermaid copy/download, loopback preview.
|
||||
@@ -552,6 +611,7 @@ export const attachMarkdownInteractions = (
|
||||
container: HTMLElement,
|
||||
ctx: DecorateContext,
|
||||
): (() => void) => {
|
||||
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
@@ -658,5 +718,8 @@ export const attachMarkdownInteractions = (
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => container.removeEventListener('click', handleClick);
|
||||
return () => {
|
||||
unregisterCodeCopy();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { parseDiffToUnified, tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
@@ -42,6 +42,29 @@ describe('getToolOutput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDiffToUnified', () => {
|
||||
test('handles a streamed diff with a bare Index header', () => {
|
||||
expect(parseDiffToUnified('Index:')).toEqual([]);
|
||||
expect(parseDiffToUnified('Index:\n@@ -1,1 +1,1 @@\n-old\n+new')).toEqual([
|
||||
{
|
||||
file: 'file',
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: 'removed', lineNumber: 1, content: 'old' },
|
||||
{ type: 'added', lineNumber: 1, content: 'new' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves spaces when extracting the indexed filename', () => {
|
||||
const [hunk] = parseDiffToUnified('Index: src/my file.ts\n@@ -1,1 +1,1 @@\n-old\n+new');
|
||||
|
||||
expect(hunk?.file).toBe('my file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTerminalOutput', () => {
|
||||
test('renders carriage-return progress updates as their latest value', () => {
|
||||
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
|
||||
|
||||
@@ -575,7 +575,7 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
|
||||
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
if (line.startsWith('Index:')) {
|
||||
currentFile = line.split(' ')[1].split('/').pop() || 'file';
|
||||
currentFile = line.slice('Index:'.length).trim().split('/').pop() || 'file';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
|
||||
@@ -943,7 +943,12 @@ export const ContextPanel: React.FC = () => {
|
||||
: activeTab?.mode === 'notes'
|
||||
? <ProjectContextPanel />
|
||||
: activeTab?.mode === 'plan'
|
||||
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} projectPlanId={activeTab.projectPlanId} /></React.Suspense>
|
||||
? <React.Suspense fallback={null}><PlanView
|
||||
targetPath={activeTab.targetPath}
|
||||
savedProjectPlan={activeTab.projectPlanId && activeTab.projectPlanRef
|
||||
? { projectRef: activeTab.projectPlanRef, planId: activeTab.projectPlanId }
|
||||
: null}
|
||||
/></React.Suspense>
|
||||
: null;
|
||||
|
||||
const browserTabs = React.useMemo(
|
||||
|
||||
@@ -6,63 +6,53 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const ProjectContextPanel: React.FC<{
|
||||
onActionComplete?: () => void;
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
}> = ({ onActionComplete, onOpenPlan }) => {
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const { t } = useI18n();
|
||||
const gitDirectories = useGitStore((state) => state.directories);
|
||||
const isChatContext = useSessionUIStore((state) => (
|
||||
state.newSessionDraft.open
|
||||
? state.newSessionDraft.target === 'chat'
|
||||
: isChatDirectoryPath(state.currentSessionDirectory)
|
||||
));
|
||||
const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory);
|
||||
|
||||
const activeProject = React.useMemo(() => {
|
||||
if (isChatContext) return null;
|
||||
if (activeProjectId) {
|
||||
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
|
||||
}
|
||||
return projects[0] ?? null;
|
||||
}, [activeProjectId, isChatContext, projects]);
|
||||
// One owner decision shared with the panel, agent memory, and PlanView:
|
||||
// chats resolve to the Chats owner, worktrees to their project, and an
|
||||
// unrecognized directory owns nothing (null) rather than borrowing
|
||||
// whichever project happens to be active.
|
||||
const projectRef = useProjectContextOwner(chatSessionDirectory);
|
||||
|
||||
const projectRef = React.useMemo(() => {
|
||||
if (isChatContext && chatsRoot) {
|
||||
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
|
||||
}
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: activeProject.id,
|
||||
path: activeProject.path,
|
||||
};
|
||||
}, [activeProject, chatsRoot, isChatContext]);
|
||||
// Display-only lookup: a user-renamed project label wins over the directory
|
||||
// name. The owner decision stays with the hook — this must not reintroduce
|
||||
// a fallback.
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const labeledProject = React.useMemo(
|
||||
() => (projectRef ? projects.find((project) => project.id === projectRef.id) ?? null : null),
|
||||
[projectRef, projects],
|
||||
);
|
||||
|
||||
const projectLabel = React.useMemo(() => {
|
||||
if (isChatContext) return t('sessions.sidebar.activity.chatsTitle');
|
||||
if (!activeProject) {
|
||||
if (!projectRef) {
|
||||
return null;
|
||||
}
|
||||
return activeProject.label?.trim()
|
||||
|| formatDirectoryName(activeProject.path, homeDirectory)
|
||||
|| activeProject.path;
|
||||
}, [activeProject, homeDirectory, isChatContext, t]);
|
||||
if (projectRef.id === CHAT_DRAFT_PROJECT_ID) {
|
||||
return t('sessions.sidebar.activity.chatsTitle');
|
||||
}
|
||||
return labeledProject?.label?.trim()
|
||||
|| formatDirectoryName(projectRef.path, homeDirectory)
|
||||
|| projectRef.path;
|
||||
}, [homeDirectory, labeledProject, projectRef, t]);
|
||||
|
||||
const canCreateWorktree = React.useMemo(() => {
|
||||
if (!activeProject) {
|
||||
if (!projectRef || projectRef.id === CHAT_DRAFT_PROJECT_ID) {
|
||||
return false;
|
||||
}
|
||||
return gitDirectories.get(activeProject.path)?.isGitRepo === true;
|
||||
}, [activeProject, gitDirectories]);
|
||||
return gitDirectories.get(projectRef.path)?.isGitRepo === true;
|
||||
}, [gitDirectories, projectRef]);
|
||||
|
||||
return (
|
||||
/* The panel scrolls its own tab content; a scroller here would nest. */
|
||||
|
||||
@@ -205,7 +205,7 @@ const getFallbackInstallCommand = (provider: string, platform = getClientInstall
|
||||
if (platform === 'darwin') {
|
||||
return 'brew install cloudflared';
|
||||
}
|
||||
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/';
|
||||
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/';
|
||||
};
|
||||
|
||||
const createTunnelDependencyInstallInfo = (provider: string, checkData?: TunnelCheckResponse): TunnelDependencyInstallInfo => {
|
||||
|
||||
@@ -359,7 +359,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
||||
const highlightedRow = rows[highlightedIndex] ?? null;
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
highlightedRow && (highlightedRow.type === 'up' || highlightedRow.type === 'directory')
|
||||
);
|
||||
const submitModifierLabel = formatShortcutForDisplay('mod');
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
@@ -483,7 +483,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
if (row.path) browseToDisplayPath(row.path);
|
||||
return;
|
||||
}
|
||||
if (row.disabled) return;
|
||||
browseToEntry(row);
|
||||
}, [browseToDisplayPath, browseToEntry]);
|
||||
|
||||
@@ -662,7 +661,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
disabled={row.type === 'directory' && row.disabled}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => executeRow(row)}
|
||||
@@ -670,7 +668,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
isActive && 'bg-interactive-selection text-interactive-selection-foreground',
|
||||
!isActive && 'hover:bg-interactive-hover/50',
|
||||
row.type === 'directory' && row.disabled && 'cursor-not-allowed opacity-45 hover:bg-transparent'
|
||||
row.type === 'directory' && row.disabled && 'opacity-45'
|
||||
)}
|
||||
>
|
||||
{row.type === 'up' ? (
|
||||
|
||||
@@ -60,6 +60,18 @@ Leaving the section or the project closes it, so its editor never sits over a
|
||||
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
|
||||
still pass `onOpenPlan` and keep theirs.
|
||||
|
||||
The panel owns the only source of truth for which project a plan belongs to,
|
||||
and it never lets the editor guess. `PlanView` receives the owner as
|
||||
`savedProjectPlan={{ projectRef, planId }}` — load and autosave both go to that
|
||||
exact project. An earlier version let the editor re-derive the project from the
|
||||
current directory, which silently opened an empty document for plans stored
|
||||
under the managed Chats owner (`openchamber:chats`), for plans opened from a
|
||||
worktree the directory lookup missed, and for plan tabs restored after a
|
||||
reload. Persisted plan tabs carry `projectPlanRef` for the same reason; a saved-plan
|
||||
tab persisted with an id but no owner is dropped on rehydrate rather than
|
||||
reopened against a guessed project. A plain session plan tab legitimately has
|
||||
neither an id nor an owner and is kept.
|
||||
|
||||
## Pins belong to one session
|
||||
|
||||
Notes and plans are project data, but attaching one writes its id to the current
|
||||
@@ -106,10 +118,16 @@ its own tool. It feeds this panel only — what a session is told about memory i
|
||||
decided server-side by `packages/web/server/lib/session-knowledge`, so it
|
||||
reaches sessions that have no UI at all and survives compaction.
|
||||
|
||||
Both sides resolve a worktree to its project before touching the store — the
|
||||
client through `resolveProjectForSessionDirectory`, the server through
|
||||
`agent-memory/project-resolution`. Keying by the session directory instead filed
|
||||
a worktree's memories under a project nothing reads.
|
||||
`useProjectContextOwner` is the client authority shared by this panel and the
|
||||
memory sync. It resolves managed chat directories to the Chats root and a
|
||||
worktree to its project before either consumer touches a store. The server uses
|
||||
`agent-memory/project-resolution` for the same worktree rule. Keying by a
|
||||
worktree session directory would file memories under a project nothing reads.
|
||||
|
||||
Project memory is rendered only when the store's `projectPath` matches the
|
||||
panel owner. An owner switch hides the previous project's entries before the
|
||||
new request starts. A failed request marks the new owner unavailable instead of
|
||||
presenting that hidden list as authoritative empty memory.
|
||||
|
||||
Turning the switch back on re-reads the store only after the setting has
|
||||
finished being written. The switch flips the client immediately, which makes the
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { AGENT_MEMORY_BODY_MAX_LENGTH, AGENT_MEMORY_TITLE_MAX_LENGTH, type AgentMemoryEntry, type AgentMemoryScope } from '@/lib/agentMemoryApi';
|
||||
import { classifyMemory, memoryViewKey, type MemoryBadge } from '@/lib/agentMemoryBadges';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
/**
|
||||
@@ -160,7 +160,7 @@ export const MemorySection: React.FC<{
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
const globalEntries = useAgentMemoryStore((state) => state.global);
|
||||
const projectEntries = useAgentMemoryStore((state) => state.project);
|
||||
const projectEntries = useAgentMemoryStore((state) => selectProjectMemoryForPath(state, projectPath));
|
||||
const globalFailed = useAgentMemoryStore((state) => state.globalFailed);
|
||||
const projectFailed = useAgentMemoryStore((state) => state.projectFailed);
|
||||
const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { requestFileAccess } from '@/lib/desktop';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { parsePlanMarkdown, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
|
||||
import { parsePlanMarkdown, resolveProjectContextId, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -23,8 +23,9 @@ export const PlansSection: React.FC<{
|
||||
plans: ProjectPlanLink[];
|
||||
/** Panel-wide filter, matched against plan titles. */
|
||||
query: string;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. The
|
||||
plan carries its owner so the host viewer never guesses the project. */
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
pinnedPlanIds: ReadonlySet<string>;
|
||||
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
|
||||
@@ -155,7 +156,7 @@ export const PlansSection: React.FC<{
|
||||
const handleOpenPlan = React.useCallback(
|
||||
(plan: ProjectPlanLink) => {
|
||||
if (onOpenPlan) {
|
||||
onOpenPlan({ id: plan.id, title: plan.title });
|
||||
onOpenPlan({ id: plan.id, title: plan.title, projectRef });
|
||||
return;
|
||||
}
|
||||
const panelDirectory = currentDirectory?.trim() || projectRef.path.trim();
|
||||
@@ -165,11 +166,15 @@ export const PlansSection: React.FC<{
|
||||
openContextPanelTab(panelDirectory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: plan.id,
|
||||
dedupeKey: `plan:${plan.id}`,
|
||||
projectPlanRef: projectRef,
|
||||
// Storage identity is derived from the project path, not the settings
|
||||
// id, so the tab identity uses the same derivation. Two projects
|
||||
// sharing a settings id but not a path must not merge plan tabs.
|
||||
dedupeKey: `plan:${resolveProjectContextId(projectRef)}:${plan.id}`,
|
||||
label: plan.title,
|
||||
});
|
||||
},
|
||||
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef.path]
|
||||
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges';
|
||||
import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -29,8 +29,9 @@ interface ProjectNotesTodoPanelProps {
|
||||
canCreateWorktree?: boolean;
|
||||
onActionComplete?: () => void;
|
||||
/** When provided, opening a plan calls this instead of the desktop context
|
||||
panel tab — hosts without ContextPanel (mobile) render their own viewer. */
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
panel tab — hosts without ContextPanel (mobile) render their own viewer.
|
||||
The plan carries its owner so the host's viewer cannot guess wrong. */
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -133,7 +134,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled);
|
||||
const memoryVisible = memoryEnabled && !memoryDisabledByServer;
|
||||
const globalMemory = useAgentMemoryStore((state) => state.global);
|
||||
const projectMemory = useAgentMemoryStore((state) => state.project);
|
||||
const projectMemory = useAgentMemoryStore(
|
||||
(state) => selectProjectMemoryForPath(state, projectRef?.path ?? null),
|
||||
);
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const storedTab = useUIStore((state) => state.projectContextTab);
|
||||
@@ -499,10 +502,10 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'plans' && openPlan ? (
|
||||
{activeTab === 'plans' && openPlan && projectRef ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PlanView
|
||||
projectPlanId={openPlan.id}
|
||||
savedProjectPlan={{ projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => setOpenPlan(null)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
@@ -38,7 +38,10 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { fetchProjectPlan, parsePlanMarkdown } from '@/lib/projectContextApi';
|
||||
import { fetchProjectPlan, parsePlanMarkdown, resolveProjectContextId, type SavedProjectPlanTarget } from '@/lib/projectContextApi';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { createPlanSaveQueue } from '@/lib/planSaveQueue';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
|
||||
@@ -49,9 +52,12 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type PlanViewProps = {
|
||||
targetPath?: string | null;
|
||||
/** Saved project plan to open. Project plans are server-owned and addressed
|
||||
by id; they never carry a client-visible filesystem path. */
|
||||
projectPlanId?: string | null;
|
||||
/** Saved project plan to open, with the project that owns it. The owner is
|
||||
part of the prop so the view never guesses it from the current directory:
|
||||
plan tabs outlive directory changes (persisted context tabs, mobile
|
||||
overlays), and for managed chats the owner is not a registered project a
|
||||
directory lookup could ever find. */
|
||||
savedProjectPlan?: SavedProjectPlanTarget | null;
|
||||
/** Called after a send action routes the user to the chat — hosts that show
|
||||
PlanView in an overlay (mobile fullscreen surface) close it here. */
|
||||
onNavigatedToChat?: () => void;
|
||||
@@ -149,12 +155,16 @@ const resolveProjectRefForDirectory = (
|
||||
return match ? { id: match.id, path: match.path } : null;
|
||||
};
|
||||
|
||||
const subscribeActiveRuntimeKey = (onStoreChange: () => void): (() => void) => {
|
||||
return subscribeRuntimeEndpointChanged(() => onStoreChange());
|
||||
};
|
||||
|
||||
type SelectedLineRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPlanId = null, onNavigatedToChat }) => {
|
||||
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, savedProjectPlan = null, onNavigatedToChat }) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
@@ -170,6 +180,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const activeRuntimeKey = React.useSyncExternalStore(subscribeActiveRuntimeKey, getRuntimeKey, getRuntimeKey);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
@@ -190,9 +201,37 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
|
||||
[activeProjectId, projectDirectory, projects],
|
||||
);
|
||||
// Destructured to primitives so the load/save effects key on stable values
|
||||
// instead of a descriptor object rebuilt on every parent render.
|
||||
const savedPlanProjectId = savedProjectPlan?.projectRef.id ?? null;
|
||||
const savedPlanProjectPath = savedProjectPlan?.projectRef.path ?? null;
|
||||
const savedPlanProjectRef = React.useMemo(
|
||||
() => savedPlanProjectId && savedPlanProjectPath
|
||||
? { id: savedPlanProjectId, path: savedPlanProjectPath }
|
||||
: null,
|
||||
[savedPlanProjectId, savedPlanProjectPath],
|
||||
);
|
||||
const savedPlanId = savedProjectPlan?.planId ?? null;
|
||||
// Stable logical identity, composed from primitives: an effect keyed on the
|
||||
// descriptor object would reload — and flush — the same plan whenever a
|
||||
// parent rebuilds the owner object with identical values.
|
||||
const savedPlanKey = savedPlanProjectRef && savedPlanId
|
||||
? JSON.stringify(['saved-plan', activeRuntimeKey, resolveProjectContextId(savedPlanProjectRef), savedPlanId])
|
||||
: null;
|
||||
// Managed chats have no project directory to create a session in: their
|
||||
// sessions live in per-session directories under the chats root, which
|
||||
// createSession cannot prepare. Until a managed-chat send path exists,
|
||||
// Improve/Implement stay unavailable for plans stored under the Chats
|
||||
// owner — an OpenCode session created directly in the shared root would
|
||||
// break the managed-chats model.
|
||||
const isManagedChatPlan = savedPlanProjectRef?.id === CHAT_DRAFT_PROJECT_ID;
|
||||
const canCreateWorktree = React.useMemo(
|
||||
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
|
||||
[currentProjectRef, gitDirectories],
|
||||
() => {
|
||||
// Worktree creation follows the session the plan would be sent to.
|
||||
const sendTarget = savedPlanProjectRef ?? currentProjectRef;
|
||||
return sendTarget ? gitDirectories.get(sendTarget.path)?.isGitRepo === true : false;
|
||||
},
|
||||
[currentProjectRef, gitDirectories, savedPlanProjectRef],
|
||||
);
|
||||
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
|
||||
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
|
||||
@@ -202,7 +241,6 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
// `resolvedPath` so nothing downstream can mistake a project plan for a file
|
||||
// the user could open, edit, or be shown a path for.
|
||||
const [loadedProjectPlanId, setLoadedProjectPlanId] = React.useState<string | null>(null);
|
||||
const savePlan = useProjectContextStore((state) => state.savePlan);
|
||||
const hasDocument = Boolean(resolvedPath) || Boolean(loadedProjectPlanId);
|
||||
const displayPath = React.useMemo(() => {
|
||||
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
|
||||
@@ -214,6 +252,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
const [saveError, setSaveError] = React.useState<string | null>(null);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const planFileLabel = React.useMemo(() => {
|
||||
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
|
||||
}, [displayPath, t]);
|
||||
@@ -381,9 +420,96 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
return extensions;
|
||||
}, [currentTheme, resolvedPath, editorFontSize]);
|
||||
|
||||
// Pending-save bookkeeping for the open document. One ref record, not state:
|
||||
// debounced writes and close-time flushes must read the newest buffer and
|
||||
// revision without another render. `editRevision` advances on every editor
|
||||
// change; `savedRevision` only after a successful write of that exact
|
||||
// revision, so a slow in-flight save can never mark newer edits as saved.
|
||||
// `key` and `runtimeKey` make every write self-identifying: content never
|
||||
// crosses documents or runtimes, no matter when a queued write settles.
|
||||
const docRef = React.useRef<{
|
||||
key: string | null;
|
||||
target: SavedProjectPlanTarget | { filePath: string } | null;
|
||||
content: string;
|
||||
editRevision: number;
|
||||
savedRevision: number;
|
||||
runtimeKey: string;
|
||||
}>({ key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' });
|
||||
const saveQueue = React.useState(createPlanSaveQueue)[0];
|
||||
|
||||
// Filesystem writes keep the runtime adapter precedence the view always
|
||||
// used: the active RuntimeAPIs first, the registry as fallback.
|
||||
const writeDocument = React.useCallback(async (target: NonNullable<typeof docRef.current['target']>, text: string): Promise<void> => {
|
||||
if ('filePath' in target) {
|
||||
const files = runtimeApis.files ?? getRegisteredRuntimeAPIs()?.files;
|
||||
if (files?.writeFile) {
|
||||
const result = await files.writeFile(target.filePath, text);
|
||||
if (!result?.success) {
|
||||
throw new Error('Plan file write failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await runtimeFetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target.filePath, content: text }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to write plan file (${response.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const saved = await useProjectContextStore.getState().savePlan(target.projectRef, target.planId, text);
|
||||
if (!saved) {
|
||||
throw new Error('Plan save rejected: the plan no longer exists');
|
||||
}
|
||||
}, [runtimeApis.files]);
|
||||
const writeDocumentRef = React.useRef(writeDocument);
|
||||
writeDocumentRef.current = writeDocument;
|
||||
|
||||
// Queue any unflushed edits. Runs on document switches and on unmount, both
|
||||
// of which cancel the debounced save — without this the last 350ms of typing
|
||||
// is silently dropped. The queue orders it behind any write already in
|
||||
// flight for the same document, and the captured runtime key stops content
|
||||
// from one host being written into another after a runtime switch.
|
||||
const scheduleSave = React.useCallback(() => {
|
||||
const doc = docRef.current;
|
||||
if (!doc.key || !doc.target || doc.editRevision <= doc.savedRevision) {
|
||||
return;
|
||||
}
|
||||
const captured = {
|
||||
key: doc.key,
|
||||
target: doc.target,
|
||||
content: doc.content,
|
||||
revision: doc.editRevision,
|
||||
runtimeKey: doc.runtimeKey,
|
||||
write: writeDocumentRef.current,
|
||||
};
|
||||
saveQueue.schedule(captured.key, captured.revision, async () => {
|
||||
if (getRuntimeKey() !== captured.runtimeKey) {
|
||||
// The runtime switched while this write waited: writing through the
|
||||
// new connection would land one host's edits on another.
|
||||
return;
|
||||
}
|
||||
await captured.write(captured.target, captured.content);
|
||||
const current = docRef.current;
|
||||
if (current.key === captured.key) {
|
||||
current.savedRevision = Math.max(current.savedRevision, captured.revision);
|
||||
// A recovered save clears the stale failure banner.
|
||||
setSaveError(null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (docRef.current.key === captured.key) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Plan save failed');
|
||||
}
|
||||
});
|
||||
}, [saveQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Saved project plans opened via context panel should work even when session plan mode is off.
|
||||
if (!planModeEnabled && !targetPath && !projectPlanId) {
|
||||
if (!planModeEnabled && !targetPath && !savedPlanId) {
|
||||
scheduleSave();
|
||||
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
|
||||
setResolvedPath(null);
|
||||
setLoadedProjectPlanId(null);
|
||||
setContent('');
|
||||
@@ -416,31 +542,49 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
// Flush the outgoing document before the bookkeeping is replaced, so
|
||||
// edits typed within the debounce window survive a plan switch. React
|
||||
// reuses this component instance across saved-plan tabs.
|
||||
scheduleSave();
|
||||
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
|
||||
setResolvedPath(null);
|
||||
setLoadedProjectPlanId(null);
|
||||
setContent('');
|
||||
setSaveError(null);
|
||||
setLoadError(null);
|
||||
|
||||
if (projectPlanId) {
|
||||
if (!currentProjectRef) {
|
||||
return;
|
||||
}
|
||||
if (savedPlanId && savedPlanProjectRef && savedPlanKey) {
|
||||
// A plan re-opened while its own flush is still writing must read the
|
||||
// post-write state, not race it. The queue reset afterwards is safe:
|
||||
// every write for this key has settled, and the reloaded document
|
||||
// restarts its revision counter at zero.
|
||||
await saveQueue.pendingFor(savedPlanKey);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(savedPlanKey);
|
||||
setLoading(true);
|
||||
try {
|
||||
const plan = await fetchProjectPlan(currentProjectRef, projectPlanId);
|
||||
const plan = await fetchProjectPlan(savedPlanProjectRef, savedPlanId);
|
||||
if (cancelled) return;
|
||||
if (!plan) {
|
||||
// The plan or its markdown is gone. Leave the view empty and
|
||||
// unsaveable rather than presenting an editor that would recreate
|
||||
// a document the user deleted.
|
||||
setSaveError(t('planView.error.loadFailed'));
|
||||
setLoadError('Plan not found');
|
||||
return;
|
||||
}
|
||||
docRef.current = {
|
||||
key: savedPlanKey,
|
||||
target: { projectRef: savedPlanProjectRef, planId: savedPlanId },
|
||||
content: plan.raw,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setContent(plan.raw);
|
||||
setLoadedProjectPlanId(projectPlanId);
|
||||
setLoadedProjectPlanId(savedPlanId);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setSaveError(error instanceof Error ? error.message : t('planView.error.loadFailed'));
|
||||
setLoadError(error instanceof Error ? error.message : 'Plan load failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -448,10 +592,22 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
const fileKey = JSON.stringify(['plan-file', activeRuntimeKey, targetPath]);
|
||||
await saveQueue.pendingFor(fileKey);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(fileKey);
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = await readText(targetPath);
|
||||
if (cancelled) return;
|
||||
docRef.current = {
|
||||
key: fileKey,
|
||||
target: { filePath: targetPath },
|
||||
content: text,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setResolvedPath(targetPath);
|
||||
setContent(text);
|
||||
} catch {
|
||||
@@ -477,10 +633,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null);
|
||||
|
||||
let resolved: string | null = null;
|
||||
let text: string | null = null;
|
||||
|
||||
try {
|
||||
text = await readText(repoPath);
|
||||
await readText(repoPath);
|
||||
resolved = repoPath;
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -488,7 +643,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
|
||||
if (!resolved) {
|
||||
try {
|
||||
text = await readText(homePath);
|
||||
await readText(homePath);
|
||||
resolved = homePath;
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -497,12 +652,26 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!resolved || text === null) {
|
||||
if (!resolved) {
|
||||
setResolvedPath(null);
|
||||
setContent('');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionFileKey = JSON.stringify(['plan-file', activeRuntimeKey, resolved]);
|
||||
await saveQueue.pendingFor(sessionFileKey);
|
||||
if (cancelled) return;
|
||||
const text = await readText(resolved);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(sessionFileKey);
|
||||
docRef.current = {
|
||||
key: sessionFileKey,
|
||||
target: { filePath: resolved },
|
||||
content: text,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setResolvedPath(resolved);
|
||||
setContent(text);
|
||||
} catch {
|
||||
@@ -519,55 +688,42 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentProjectRef, homeDirectory, planModeEnabled, projectPlanId, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, t, targetPath]);
|
||||
}, [activeRuntimeKey, homeDirectory, planModeEnabled, runtimeApis.files, savedPlanId, savedPlanKey, savedPlanProjectRef, saveQueue, scheduleSave, session?.slug, session?.time?.created, sessionDirectory, targetPath]);
|
||||
|
||||
// Synchronous buffer tracking: if an edit and an unmount land in the same
|
||||
// batch, the passive content effect would never run and a flush would save
|
||||
// a stale buffer.
|
||||
const handleContentChange = React.useCallback((next: string) => {
|
||||
docRef.current.content = next;
|
||||
docRef.current.editRevision += 1;
|
||||
setContent(next);
|
||||
}, []);
|
||||
|
||||
// The debounced write and the close/switch flush go through the same queue
|
||||
// (scheduleSave), so two saves of one document can never complete out of
|
||||
// order and a flush never duplicates a debounce of the same revision.
|
||||
React.useEffect(() => {
|
||||
if (!resolvedPath && !loadedProjectPlanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = window.setTimeout(async () => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
if (loadedProjectPlanId) {
|
||||
if (!currentProjectRef) {
|
||||
throw new Error(t('planView.error.writeFailed'));
|
||||
}
|
||||
const saved = await savePlan(currentProjectRef, loadedProjectPlanId, content);
|
||||
if (!saved) {
|
||||
throw new Error(t('planView.error.writeFailed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (runtimeApis.files?.writeFile) {
|
||||
const result = await runtimeApis.files.writeFile(resolvedPath, content);
|
||||
if (!result?.success) {
|
||||
throw new Error(t('planView.error.writeFailed'));
|
||||
}
|
||||
} else {
|
||||
const response = await runtimeFetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: resolvedPath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(t('planView.error.writePlanFileFailed', { status: response.status }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed'));
|
||||
}
|
||||
const controller = window.setTimeout(() => {
|
||||
scheduleSave();
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(controller);
|
||||
};
|
||||
}, [content, currentProjectRef, loadedProjectPlanId, resolvedPath, runtimeApis.files, savePlan, t]);
|
||||
}, [content, loadedProjectPlanId, resolvedPath, scheduleSave]);
|
||||
|
||||
// Closing the view inside the 350ms debounce window would drop the last
|
||||
// edits: the cleanup above cancels the timer. Same for switching documents,
|
||||
// which the load effect handles before replacing the bookkeeping.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
scheduleSave();
|
||||
};
|
||||
}, [scheduleSave]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -584,7 +740,11 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
|
||||
const handleConfirmPlanSend = React.useCallback(
|
||||
async (execution: TodoSendExecution) => {
|
||||
if (!currentProjectRef || !pendingPlanSend) {
|
||||
// A saved plan sends against its own project — the one it is stored
|
||||
// under — not against whatever directory the viewer is currently in.
|
||||
// For filesystem plans those are the same directory.
|
||||
const sendTargetProject = savedPlanProjectRef ?? currentProjectRef;
|
||||
if (!sendTargetProject || !pendingPlanSend || isManagedChatPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -601,32 +761,45 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
plan_path: resolvedPath ?? '',
|
||||
},
|
||||
);
|
||||
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
|
||||
// Saved project plans have no file path for the agent to read. Without
|
||||
// this the instructions say "read that file" with an empty path and the
|
||||
// plan contents never reach the session, so the plan substance rides
|
||||
// along in the synthetic message instead.
|
||||
const planSubstance = resolvedPath
|
||||
? instructionsText
|
||||
: [
|
||||
instructionsText,
|
||||
'',
|
||||
'The plan is not stored as a file in the repository and has no file path. Its full current contents follow below this note and are the source of truth for the plan. Where the instructions above refer to the plan file, treat the plan as stored in OpenChamber project knowledge (it is edited through the OpenChamber UI): propose plan revisions as plan text in the chat rather than editing a file.',
|
||||
'',
|
||||
content,
|
||||
].join('\n');
|
||||
const syntheticParts = [{ synthetic: true as const, text: planSubstance }];
|
||||
setIsPlanSendSubmitting(true);
|
||||
|
||||
try {
|
||||
routeToChat();
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let directoryHint: string | null = currentProjectRef.path;
|
||||
let directoryHint: string | null = sendTargetProject.path;
|
||||
|
||||
if (pendingPlanSend.target === 'worktree') {
|
||||
if (!canCreateWorktree) {
|
||||
return;
|
||||
}
|
||||
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
|
||||
const created = await createWorktreeSessionForNewBranch(sendTargetProject.path, generateBranchName());
|
||||
if (!created?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = created.id;
|
||||
directoryHint = created.path;
|
||||
} else {
|
||||
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
|
||||
const sessionResult = await createSession(undefined, sendTargetProject.path, null);
|
||||
if (!sessionResult?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = sessionResult.id;
|
||||
directoryHint = sessionResult.directory ?? currentProjectRef.path;
|
||||
directoryHint = sessionResult.directory ?? sendTargetProject.path;
|
||||
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
|
||||
}
|
||||
|
||||
@@ -664,8 +837,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
// source. Here we only compose header + full content.
|
||||
const goalObjective = execution.runAsGoal === true
|
||||
? [
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
|
||||
'Re-read that file for full details — it is the source of truth.',
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ' (the full plan follows)'}.`,
|
||||
resolvedPath
|
||||
? 'Re-read that file for full details — it is the source of truth.'
|
||||
: 'The full plan follows in this message and is the source of truth.',
|
||||
'',
|
||||
content,
|
||||
].join('\n')
|
||||
@@ -687,7 +862,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
setIsPlanSendSubmitting(false);
|
||||
}
|
||||
},
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, isManagedChatPlan, pendingPlanSend, resolvedPath, routeToChat, savedPlanProjectRef, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
);
|
||||
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
@@ -716,6 +891,11 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
|
||||
{loadError ? (
|
||||
<div className="typography-micro text-[color:var(--status-error)] truncate" title={loadError}>
|
||||
{t('planView.error.loadFailed')}
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
|
||||
{t('planView.error.saveFailed')}
|
||||
@@ -733,7 +913,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label={t('planView.actions.improvePlanAria')}
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || isManagedChatPlan}
|
||||
>
|
||||
<Icon name="loop-right-ai" className="size-4" />
|
||||
</Button>
|
||||
@@ -742,7 +922,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}
|
||||
disabled={isManagedChatPlan}
|
||||
>
|
||||
{t('planView.actions.sendToNewSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -762,7 +945,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label={t('planView.actions.implementPlanAria')}
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || isManagedChatPlan}
|
||||
>
|
||||
<Icon name="code-ai" className="size-4" />
|
||||
</Button>
|
||||
@@ -771,7 +954,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}
|
||||
disabled={isManagedChatPlan}
|
||||
>
|
||||
{t('planView.actions.sendToNewSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -853,7 +1039,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
}
|
||||
}}
|
||||
target={pendingPlanSend?.target ?? 'session'}
|
||||
projectDirectory={currentProjectRef?.path ?? null}
|
||||
projectDirectory={savedPlanProjectRef?.path ?? currentProjectRef?.path ?? null}
|
||||
submitting={isPlanSendSubmitting}
|
||||
allowRunAsGoal
|
||||
onConfirm={handleConfirmPlanSend}
|
||||
@@ -885,7 +1071,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<div className="relative h-full" ref={editorWrapperRef}>
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onChange={handleContentChange}
|
||||
readOnly={false}
|
||||
className="h-full"
|
||||
extensions={editorExtensions}
|
||||
|
||||
@@ -13,12 +13,10 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
|
||||
|
||||
/**
|
||||
* The directory is a parameter rather than read from `useEffectiveDirectory`,
|
||||
@@ -29,18 +27,9 @@ export const useAgentMemorySync = (directory: string | null): void => {
|
||||
const enabled = useUIStore((state) => (
|
||||
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
|
||||
));
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const effectiveDirectory = directory ?? '';
|
||||
const load = useAgentMemoryStore((state) => state.load);
|
||||
|
||||
const projectPath = React.useMemo(() => {
|
||||
if (!effectiveDirectory) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, effectiveDirectory);
|
||||
return resolved?.path ?? null;
|
||||
}, [availableWorktreesByProject, effectiveDirectory, projects]);
|
||||
const owner = useProjectContextOwner(directory);
|
||||
const projectPath = owner?.path ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { resolveProjectContextOwner } from './useProjectContextOwner';
|
||||
|
||||
const projects = [
|
||||
{ id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' },
|
||||
];
|
||||
|
||||
describe('resolveProjectContextOwner', () => {
|
||||
test('resolves a managed chat directory to the Chats root instead of the active project', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/Users/test/.config/openchamber/chats/2026-08-27/session-a',
|
||||
activeProjectId: 'openchamber',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toEqual({
|
||||
id: CHAT_DRAFT_PROJECT_ID,
|
||||
path: '/Users/test/.config/openchamber/chats',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves a worktree session to its owning project', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map([
|
||||
['/workspace/openchamber', [{
|
||||
path: '/workspace/openchamber-feature',
|
||||
projectDirectory: '/workspace/openchamber',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
}]],
|
||||
]),
|
||||
directory: '/workspace/openchamber-feature',
|
||||
activeProjectId: null,
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
|
||||
});
|
||||
|
||||
test('returns null for a recognized directory that owns nothing, instead of borrowing the active project', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/some/other/project',
|
||||
activeProjectId: 'openchamber',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to the active project only when there is no directory at all', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: null,
|
||||
activeProjectId: 'openchamber',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
|
||||
});
|
||||
|
||||
test('never falls back to the first project when the active project is unknown', () => {
|
||||
const owner = resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: null,
|
||||
activeProjectId: 'missing-project',
|
||||
chatDraftOpen: false,
|
||||
chatDraftTarget: 'project',
|
||||
homeDirectory: '/Users/test',
|
||||
});
|
||||
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
interface ProjectContextOwnerInput {
|
||||
projects: ProjectEntry[];
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
directory: string | null;
|
||||
activeProjectId: string | null;
|
||||
chatDraftOpen: boolean;
|
||||
chatDraftTarget: 'chat' | 'project';
|
||||
homeDirectory: string | null;
|
||||
}
|
||||
|
||||
export const resolveProjectContextOwner = ({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory,
|
||||
activeProjectId,
|
||||
chatDraftOpen,
|
||||
chatDraftTarget,
|
||||
homeDirectory,
|
||||
}: ProjectContextOwnerInput): ProjectRef | null => {
|
||||
const chatsRoot = getChatsRootFromDirectory(directory) ?? getChatsRootForHome(homeDirectory);
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
const normalizedChatsRoot = normalizePath(chatsRoot);
|
||||
const ownsChats = chatDraftOpen
|
||||
? chatDraftTarget === 'chat'
|
||||
: Boolean(normalizedDirectory && normalizedChatsRoot && (
|
||||
normalizedDirectory === normalizedChatsRoot || normalizedDirectory.startsWith(`${normalizedChatsRoot}/`)
|
||||
));
|
||||
|
||||
if (ownsChats && chatsRoot) {
|
||||
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
|
||||
}
|
||||
|
||||
const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, directory);
|
||||
if (sessionProject) {
|
||||
return { id: sessionProject.id, path: sessionProject.path };
|
||||
}
|
||||
|
||||
// A concrete directory that resolves to nothing owns nothing. Falling back
|
||||
// to the active project here showed one project's knowledge under another
|
||||
// project's name (the "plans open empty" bug), so the panel stays empty
|
||||
// instead of lying. The active-project fallback is only for states with no
|
||||
// directory at all, such as a new-session draft that has not landed yet.
|
||||
if (normalizedDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProject = projects.find((project) => project.id === activeProjectId) ?? null;
|
||||
return activeProject ? { id: activeProject.id, path: activeProject.path } : null;
|
||||
};
|
||||
|
||||
/** The single owner used by Project knowledge and agent-memory synchronization. */
|
||||
export const useProjectContextOwner = (directory: string | null): ProjectRef | null => {
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const chatDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
|
||||
const chatDraftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
||||
|
||||
return React.useMemo(() => resolveProjectContextOwner({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory,
|
||||
activeProjectId,
|
||||
chatDraftOpen,
|
||||
chatDraftTarget,
|
||||
homeDirectory,
|
||||
}), [
|
||||
activeProjectId,
|
||||
chatDraftOpen,
|
||||
chatDraftTarget,
|
||||
directory,
|
||||
homeDirectory,
|
||||
projects,
|
||||
worktreesByProject,
|
||||
]);
|
||||
};
|
||||
@@ -1432,6 +1432,10 @@ html:not(.dark) .chat-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdown-content [data-md-code-line-number]::before {
|
||||
content: attr(data-md-code-line-number);
|
||||
}
|
||||
|
||||
.markdown-content [data-md-code-line-content] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { copyMarkdownToClipboard } from './clipboard';
|
||||
import { flattenAssistantTextParts } from './messages/messageText';
|
||||
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
const originalClipboardItem = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');
|
||||
@@ -84,4 +87,52 @@ describe('copyMarkdownToClipboard', () => {
|
||||
expect(result).toEqual({ ok: true, method: 'clipboard' });
|
||||
expect(fallbackText).toBe('# title');
|
||||
});
|
||||
|
||||
test('assistant copy payload keeps Markdown block separation in every clipboard format', async () => {
|
||||
let writtenItem: { data: Record<string, Blob> } | undefined;
|
||||
class FakeClipboardItem {
|
||||
static supports(type: string): boolean {
|
||||
return type === 'text/markdown';
|
||||
}
|
||||
|
||||
readonly data: Record<string, Blob>;
|
||||
|
||||
constructor(data: Record<string, Blob>) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem });
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
write: async (items: Array<{ data: Record<string, Blob> }>) => {
|
||||
writtenItem = items[0];
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parts = [
|
||||
{ id: 'p0', sessionID: 's', messageID: 'm', type: 'text', text: '第一段' },
|
||||
{ id: 'p1', sessionID: 's', messageID: 'm', type: 'text', text: '第二段' },
|
||||
{ id: 'p2', sessionID: 's', messageID: 'm', type: 'text', text: '```js\nconsole.log(1)\n\n\nconsole.log(2)\n```' },
|
||||
{ id: 'p3', sessionID: 's', messageID: 'm', type: 'text', text: '第三段' },
|
||||
];
|
||||
|
||||
// Same path as ChatMessage.tsx handleCopyMessage:
|
||||
const text = flattenAssistantTextParts(parts as Parameters<typeof flattenAssistantTextParts>[0]);
|
||||
const html = marked.parse(text, { gfm: true, breaks: false }) as string;
|
||||
const result = await copyMarkdownToClipboard(text, html);
|
||||
|
||||
const expected = '第一段\n\n第二段\n\n```js\nconsole.log(1)\n\n\nconsole.log(2)\n```\n\n第三段';
|
||||
expect(result).toEqual({ ok: true, method: 'clipboard' });
|
||||
expect(await writtenItem?.data['text/plain']?.text()).toBe(expected);
|
||||
expect(await writtenItem?.data['text/markdown']?.text()).toBe(expected);
|
||||
const htmlText = await writtenItem?.data['text/html']?.text();
|
||||
expect(htmlText).toContain('<p>第一段</p>');
|
||||
expect(htmlText).toContain('<p>第二段</p>');
|
||||
expect(htmlText).not.toContain('<p>第一段\n第二段</p>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2107,6 +2107,7 @@ export const dict = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': 'Anhänge sind zu groß zum Senden. Bitte versuche, die Anzahl oder Größe der Bilder zu reduzieren.',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': 'Fehler beim Senden der Anhänge. Versuche weniger Dateien oder kleinere Bilder.',
|
||||
'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.',
|
||||
'chat.chatInput.toast.noModelSelected': 'Wähle vor dem Senden einen Anbieter und ein Modell aus.',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage',
|
||||
'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt',
|
||||
'chat.chatInput.toast.attachFileFailed': 'Fehler beim Anhängen der Datei',
|
||||
|
||||
@@ -2302,6 +2302,7 @@ export const dict = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': 'Attachments are too large to send. Please try reducing the number or size of images.',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': 'Failed to send attachments. Try fewer files or smaller images.',
|
||||
'chat.chatInput.toast.messageSendFailed': 'Message failed to send. Attachments restored.',
|
||||
'chat.chatInput.toast.noModelSelected': 'Select a provider and model before sending.',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard',
|
||||
'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)',
|
||||
'chat.chatInput.toast.attachFileFailed': 'Failed to attach file',
|
||||
|
||||
@@ -2268,6 +2268,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.attachmentsTooLarge": "Los adjuntos son demasiado grandes para enviar. Intenta reducir la cantidad o el tamaño de las imágenes.",
|
||||
"chat.chatInput.toast.sendAttachmentsFailed": "No se pudieron enviar los adjuntos. Intenta con menos archivos o imágenes más pequeñas.",
|
||||
"chat.chatInput.toast.messageSendFailed": "El mensaje no se pudo enviar. Los adjuntos se restauraron.",
|
||||
"chat.chatInput.toast.noModelSelected": "Selecciona un proveedor y un modelo antes de enviar.",
|
||||
"chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles",
|
||||
"chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo",
|
||||
"chat.chatInput.toast.attachFileFailed": "No se pudo adjuntar el archivo",
|
||||
|
||||
@@ -2015,6 +2015,7 @@ export const dict = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': 'Les pièces jointes sont trop volumineuses pour être envoyées. Veuillez essayer de réduire le nombre ou la taille des images.',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': 'Échec de l\'envoi des pièces jointes. Essayez moins de fichiers ou des images plus petites.',
|
||||
'chat.chatInput.toast.messageSendFailed': 'Le message n\'a pas pu être envoyé. Pièces jointes restaurées.',
|
||||
'chat.chatInput.toast.noModelSelected': 'Sélectionnez un fournisseur et un modèle avant d\'envoyer.',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers',
|
||||
'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}',
|
||||
'chat.chatInput.toast.attachFileFailed': 'Impossible de joindre le fichier',
|
||||
|
||||
@@ -2298,6 +2298,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': '添付ファイルが大きすぎて送信できません。画像の数またはサイズを減らしてください。',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': '添付ファイルの送信に失敗しました。ファイルを減らすかサイズを小さくしてください。',
|
||||
'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。',
|
||||
'chat.chatInput.toast.noModelSelected': '送信する前にプロバイダーとモデルを選択してください。',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました',
|
||||
'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました',
|
||||
'chat.chatInput.toast.attachFileFailed': 'ファイルの添付に失敗しました',
|
||||
|
||||
@@ -2302,6 +2302,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': '첨부 파일이 너무 커서 보낼 수 없습니다. 이미지 수나 크기를 줄여 보세요.',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': '첨부 파일 전송 실패. 파일 수나 이미지 크기를 줄여 보세요.',
|
||||
'chat.chatInput.toast.messageSendFailed': '메시지 전송에 실패했습니다. 첨부 파일을 복원했습니다.',
|
||||
'chat.chatInput.toast.noModelSelected': '전송하기 전에 제공업체와 모델을 선택하세요.',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패',
|
||||
'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨',
|
||||
'chat.chatInput.toast.attachFileFailed': '첨부 파일 실패',
|
||||
|
||||
@@ -1278,6 +1278,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka',
|
||||
'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji',
|
||||
'chat.chatInput.toast.messageSendFailed': 'Nie udało się wysłać wiadomości. Załączniki zostały przywrócone.',
|
||||
'chat.chatInput.toast.noModelSelected': 'Wybierz dostawcę i model przed wysłaniem.',
|
||||
'chat.chatInput.toast.openSessionFirst': 'Najpierw otwórz sesję',
|
||||
'chat.chatInput.toast.reviewFailed': 'Nie udało się przejrzeć zmian',
|
||||
'chat.chatInput.toast.planFeatureFailed': 'Nie udało się rozpocząć planowania funkcji',
|
||||
|
||||
@@ -2268,6 +2268,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.attachmentsTooLarge": "Os anexos são grandes demais para enviar. Tente reduzir a quantidade ou o tamanho das imagens.",
|
||||
"chat.chatInput.toast.sendAttachmentsFailed": "Não foi possível enviar os anexos. Tente com menos arquivos ou imagens menores.",
|
||||
"chat.chatInput.toast.messageSendFailed": "A mensagem não pôde ser enviada. Os anexos foram restaurados.",
|
||||
"chat.chatInput.toast.noModelSelected": "Selecione um provedor e um modelo antes de enviar.",
|
||||
"chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência",
|
||||
"chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo",
|
||||
"chat.chatInput.toast.attachFileFailed": "Não foi possível anexar o arquivo",
|
||||
|
||||
@@ -2268,6 +2268,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.attachmentsTooLarge": "Вкладені файли завеликі для надсилання. Спробуйте зменшити кількість або розмір зображень.",
|
||||
"chat.chatInput.toast.sendAttachmentsFailed": "Не вдалося надіслати вкладення. Спробуйте зменшити кількість файлів або зображень.",
|
||||
"chat.chatInput.toast.messageSendFailed": "Не вдалося надіслати повідомлення. Вкладення відновлено.",
|
||||
"chat.chatInput.toast.noModelSelected": "Виберіть постачальника та модель перед надсиланням.",
|
||||
"chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну",
|
||||
"chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}",
|
||||
"chat.chatInput.toast.attachFileFailed": "Не вдалося прикріпити файл",
|
||||
|
||||
@@ -2268,6 +2268,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': '附件过大,无法发送。请减少图片数量或大小。',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': '发送附件失败。请尝试更少文件或更小图片。',
|
||||
'chat.chatInput.toast.messageSendFailed': '消息发送失败,附件已恢复。',
|
||||
'chat.chatInput.toast.noModelSelected': '发送前请先选择提供商和模型。',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败',
|
||||
'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及',
|
||||
'chat.chatInput.toast.attachFileFailed': '附加文件失败',
|
||||
|
||||
@@ -2272,6 +2272,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.attachmentsTooLarge': '附件過大,無法傳送。請減少圖片數量或大小。',
|
||||
'chat.chatInput.toast.sendAttachmentsFailed': '傳送附件失敗。請嘗試更少檔案或更小圖片。',
|
||||
'chat.chatInput.toast.messageSendFailed': '訊息傳送失敗,附件已恢復。',
|
||||
'chat.chatInput.toast.noModelSelected': '傳送前請先選擇提供者與模型。',
|
||||
'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗',
|
||||
'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及',
|
||||
'chat.chatInput.toast.attachFileFailed': '附加檔案失敗',
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { flattenAssistantTextParts, flattenUserTextParts } from './messageText';
|
||||
|
||||
// Regression tests for https://github.com/openchamber/openchamber/issues/2867
|
||||
//
|
||||
// `flattenAssistantTextParts` used to collapse every blank line into a single
|
||||
// `\n`. Markdown block structure (paragraphs, lists, fenced code blocks)
|
||||
// requires a blank line (`\n\n`); a single `\n` is a CommonMark soft break.
|
||||
// `ChatMessage.tsx`'s `handleCopyMessage` feeds the flattened string into
|
||||
// `copyMarkdownToClipboard`, which writes it to `text/plain`, `text/markdown`
|
||||
// and its markdown-rendered HTML into `text/html`.
|
||||
|
||||
const basePart = (overrides: Record<string, unknown>): Part =>
|
||||
({
|
||||
id: 'p1',
|
||||
sessionID: 's',
|
||||
messageID: 'm',
|
||||
type: 'text',
|
||||
text: '',
|
||||
...overrides,
|
||||
}) as Part;
|
||||
|
||||
const makeParts = (texts: string[]): Part[] =>
|
||||
texts.map((text, index) => basePart({ id: `p${index}`, text }));
|
||||
|
||||
const makeUserParts = (
|
||||
entries: Array<{ text?: string; shellAction?: { output?: unknown; command?: unknown } }>,
|
||||
): Part[] =>
|
||||
entries.map((entry, index) =>
|
||||
basePart({ id: `u${index}`, text: entry.text ?? '', shellAction: entry.shellAction }),
|
||||
);
|
||||
|
||||
describe('flattenAssistantTextParts', () => {
|
||||
const parts = makeParts([
|
||||
'第一段',
|
||||
'第二段',
|
||||
'```js\nconsole.log(1)\n```',
|
||||
'第三段',
|
||||
'- item 1\n- item 2',
|
||||
]);
|
||||
|
||||
test('blank lines between paragraphs/code blocks/lists are preserved', () => {
|
||||
expect(flattenAssistantTextParts(parts)).toBe(
|
||||
'第一段\n\n第二段\n\n```js\nconsole.log(1)\n```\n\n第三段\n\n- item 1\n- item 2',
|
||||
);
|
||||
});
|
||||
|
||||
test('a code fence is not glued to the following paragraph', () => {
|
||||
const flattened = flattenAssistantTextParts(parts);
|
||||
expect(flattened).not.toContain('```\n第三段');
|
||||
expect(flattened).toContain('```\n\n第三段');
|
||||
});
|
||||
|
||||
test('list items keep single newlines inside their part', () => {
|
||||
expect(flattenAssistantTextParts(parts)).toContain('\n\n- item 1\n- item 2');
|
||||
});
|
||||
|
||||
test('internal blank-line runs are preserved', () => {
|
||||
const text = 'a\n\n\n\nb\n \n \nd';
|
||||
expect(flattenAssistantTextParts(makeParts([text]))).toBe(text);
|
||||
});
|
||||
|
||||
test('multiple blank lines inside a fenced code block are preserved', () => {
|
||||
const fenced = '```js\na\n\n\nb\n```';
|
||||
expect(flattenAssistantTextParts(makeParts([fenced]))).toBe(fenced);
|
||||
});
|
||||
|
||||
test('part boundaries produce block separators', () => {
|
||||
expect(flattenAssistantTextParts(makeParts(['first', 'second']))).toBe('first\n\nsecond');
|
||||
});
|
||||
|
||||
test('empty and whitespace-only parts are dropped', () => {
|
||||
expect(flattenAssistantTextParts([])).toBe('');
|
||||
expect(flattenAssistantTextParts(makeParts(['', ' ', '\n']))).toBe('');
|
||||
});
|
||||
|
||||
test('single part without blank lines is returned unchanged', () => {
|
||||
const single = 'only line\nsecond line';
|
||||
expect(flattenAssistantTextParts(makeParts([single]))).toBe(single);
|
||||
});
|
||||
|
||||
test('non-text parts are ignored', () => {
|
||||
const partsWithTool: Part[] = [
|
||||
...makeParts(['before']),
|
||||
{ id: 't1', sessionID: 's', messageID: 'm', type: 'tool', tool: 'bash' } as Part,
|
||||
...makeParts(['after']),
|
||||
];
|
||||
expect(flattenAssistantTextParts(partsWithTool)).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenUserTextParts', () => {
|
||||
test('plain text parts keep blank-line block separators', () => {
|
||||
const parts = makeUserParts([{ text: '第一段\n\n\n第二段' }, { text: '下一段' }]);
|
||||
expect(flattenUserTextParts(parts)).toBe('第一段\n\n\n第二段\n\n下一段');
|
||||
});
|
||||
|
||||
test('shell outputs win over other content and are joined with blank lines', () => {
|
||||
const parts = makeUserParts([
|
||||
{ text: 'note', shellAction: { command: 'ls -la' } },
|
||||
{ text: '', shellAction: { output: ' file-a\nfile-b ' } },
|
||||
{ text: '', shellAction: { output: 'done' } },
|
||||
]);
|
||||
expect(flattenUserTextParts(parts)).toBe('file-a\nfile-b\n\ndone');
|
||||
});
|
||||
|
||||
test('shell commands fall back to a single-newline command list', () => {
|
||||
const parts = makeUserParts([
|
||||
{ shellAction: { command: ' bun install ' } },
|
||||
{ shellAction: { command: 'bun test' } },
|
||||
{ text: 'ignored when commands exist' },
|
||||
]);
|
||||
expect(flattenUserTextParts(parts)).toBe('bun install\nbun test');
|
||||
});
|
||||
|
||||
test('returns empty string for parts without text', () => {
|
||||
expect(flattenUserTextParts([])).toBe('');
|
||||
expect(flattenUserTextParts(makeUserParts([{ text: ' ' }]))).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type TextLikePart = Part & { text?: string; content?: string };
|
||||
type UserTextPart = Part & { text?: string; content?: string; shellAction?: { output?: unknown; command?: unknown } };
|
||||
|
||||
export const flattenAssistantTextParts = (parts: Part[]): string => {
|
||||
const textParts = parts
|
||||
@@ -8,8 +9,36 @@ export const flattenAssistantTextParts = (parts: Part[]): string => {
|
||||
.map((part) => (part.text || part.content || '').trim())
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
return textParts.join('\n\n');
|
||||
};
|
||||
|
||||
export const flattenUserTextParts = (parts: Part[]): string => {
|
||||
const textParts = parts.filter((part): part is UserTextPart => part?.type === 'text');
|
||||
|
||||
const shellOutputs = textParts
|
||||
.map((part) => {
|
||||
const output = part.shellAction?.output;
|
||||
return typeof output === 'string' ? output.trim() : '';
|
||||
})
|
||||
.filter((output) => output.length > 0);
|
||||
if (shellOutputs.length > 0) {
|
||||
return shellOutputs.join('\n\n');
|
||||
}
|
||||
|
||||
const shellCommands = textParts
|
||||
.map((part) => {
|
||||
const command = part.shellAction?.command;
|
||||
return typeof command === 'string' ? command.trim() : '';
|
||||
})
|
||||
.filter((command) => command.length > 0);
|
||||
if (shellCommands.length > 0) {
|
||||
return shellCommands.join('\n');
|
||||
}
|
||||
|
||||
const plainTexts = textParts
|
||||
.map((part) => (part.text || part.content || '').trim())
|
||||
.filter((text) => text.length > 0);
|
||||
return plainTexts.join('\n\n');
|
||||
};
|
||||
|
||||
export const suggestPlanTitleFromText = (text: string): string => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { createPlanSaveQueue } from './planSaveQueue';
|
||||
|
||||
type Deferred = { promise: Promise<void>; resolve: () => void; reject: () => void };
|
||||
|
||||
const deferred = (): Deferred => {
|
||||
let resolve!: () => void;
|
||||
let reject!: () => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe('planSaveQueue', () => {
|
||||
test('runs writes for one document in schedule order even when they resolve out of order', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
const order: string[] = [];
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
|
||||
const firstDone = queue.schedule('doc', 1, async () => {
|
||||
await first.promise;
|
||||
order.push('first');
|
||||
});
|
||||
const secondDone = queue.schedule('doc', 2, async () => {
|
||||
order.push('second');
|
||||
});
|
||||
|
||||
// Second started only after first settles, regardless of timing.
|
||||
first.resolve();
|
||||
await firstDone;
|
||||
second.resolve();
|
||||
await secondDone;
|
||||
|
||||
expect(order).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
test('skips a revision at or below the last queued revision for the same document', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
let writes = 0;
|
||||
|
||||
await queue.schedule('doc', 3, async () => {
|
||||
writes += 1;
|
||||
});
|
||||
await queue.schedule('doc', 3, async () => {
|
||||
writes += 1;
|
||||
});
|
||||
await queue.schedule('doc', 2, async () => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
test('never lets a write for one document block another document', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
const blocked = deferred();
|
||||
|
||||
const blockedDone = queue.schedule('a', 1, async () => {
|
||||
await blocked.promise;
|
||||
});
|
||||
let otherRan = false;
|
||||
await queue.schedule('b', 1, async () => {
|
||||
otherRan = true;
|
||||
});
|
||||
|
||||
expect(otherRan).toBe(true);
|
||||
blocked.resolve();
|
||||
await blockedDone;
|
||||
});
|
||||
|
||||
test('pendingFor waits for the outstanding chain of that document only', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
const slow = deferred();
|
||||
let slowSettled = false;
|
||||
|
||||
void queue.schedule('a', 1, async () => {
|
||||
await slow.promise;
|
||||
slowSettled = true;
|
||||
});
|
||||
await queue.schedule('b', 1, async () => {});
|
||||
|
||||
await queue.pendingFor('b');
|
||||
expect(slowSettled).toBe(false);
|
||||
|
||||
slow.resolve();
|
||||
await queue.pendingFor('a');
|
||||
expect(slowSettled).toBe(true);
|
||||
});
|
||||
|
||||
test('reset clears the revision watermark so a reloaded document can save again', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
let writes = 0;
|
||||
|
||||
await queue.schedule('doc', 5, async () => {
|
||||
writes += 1;
|
||||
});
|
||||
queue.reset('doc');
|
||||
await queue.schedule('doc', 1, async () => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
test('a failed write does not poison the chain for later writes', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
|
||||
const failing = queue.schedule('doc', 1, async () => {
|
||||
throw new Error('write failed');
|
||||
});
|
||||
let secondRan = false;
|
||||
const second = queue.schedule('doc', 2, async () => {
|
||||
secondRan = true;
|
||||
});
|
||||
|
||||
await expect(failing).rejects.toThrow('write failed');
|
||||
await second;
|
||||
expect(secondRan).toBe(true);
|
||||
await queue.pendingFor('doc');
|
||||
});
|
||||
|
||||
test('allows the same revision to retry after its write fails', async () => {
|
||||
const queue = createPlanSaveQueue();
|
||||
let attempts = 0;
|
||||
|
||||
const failing = queue.schedule('doc', 1, async () => {
|
||||
attempts += 1;
|
||||
throw new Error('write failed');
|
||||
});
|
||||
await expect(failing).rejects.toThrow('write failed');
|
||||
|
||||
await queue.schedule('doc', 1, async () => {
|
||||
attempts += 1;
|
||||
});
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Write queue for open plan documents.
|
||||
*
|
||||
* Debounced autosave and close-time flushes must reach the disk in edit order,
|
||||
* and a document re-opened while its own write is still in flight must read
|
||||
* the post-write state, not race it. The queue serializes writes per logical
|
||||
* document key and deduplicates revisions so a flush of revision N can never
|
||||
* run behind, or twice behind, a debounced save of the same revision.
|
||||
*/
|
||||
|
||||
interface PlanSaveQueue {
|
||||
/**
|
||||
* Queue one write for `key`. Writes for the same key run in schedule order;
|
||||
* writes for different keys never block each other. A revision at or below
|
||||
* the last queued revision for that key is skipped — the queued write
|
||||
* already carries newer content — and the returned promise tracks the
|
||||
* outstanding chain so callers can still await it.
|
||||
*/
|
||||
schedule: (key: string, revision: number, write: () => Promise<void>) => Promise<void>;
|
||||
/** Resolves when every write queued for `key` has settled. */
|
||||
pendingFor: (key: string) => Promise<void>;
|
||||
/**
|
||||
* Forgets the revision watermark for `key`. Call when a document is freshly
|
||||
* loaded: its revision counter restarts, and stale watermarks from a
|
||||
* previous open must not swallow the first real edit.
|
||||
*/
|
||||
reset: (key: string) => void;
|
||||
}
|
||||
|
||||
export const createPlanSaveQueue = (): PlanSaveQueue => {
|
||||
const chains = new Map<string, Promise<void>>();
|
||||
const lastRevision = new Map<string, number>();
|
||||
|
||||
return {
|
||||
schedule: (key, revision, write) => {
|
||||
if (revision <= (lastRevision.get(key) ?? Number.NEGATIVE_INFINITY)) {
|
||||
return chains.get(key) ?? Promise.resolve();
|
||||
}
|
||||
lastRevision.set(key, revision);
|
||||
const previous = chains.get(key) ?? Promise.resolve();
|
||||
// A failed write must not poison the chain: the next write for this
|
||||
// document is still safe to attempt, and error surfacing belongs to the
|
||||
// caller that owns UI state.
|
||||
const next = previous.then(write, write);
|
||||
chains.set(key, next.catch(() => {
|
||||
// Keep newer queued revisions deduplicated, but let the caller retry
|
||||
// this exact revision after its write has failed.
|
||||
if (lastRevision.get(key) === revision) {
|
||||
lastRevision.delete(key);
|
||||
}
|
||||
}));
|
||||
return next;
|
||||
},
|
||||
pendingFor: async (key) => {
|
||||
await chains.get(key);
|
||||
},
|
||||
reset: (key) => {
|
||||
lastRevision.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -57,6 +57,17 @@ export interface ProjectRef {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved project plan plus the project that owns it, carried as one value so
|
||||
* a viewer can never end up with a plan id whose owner it has to guess.
|
||||
* PlanView resolves no owner on its own: the panel (or the persisted tab,
|
||||
* or the mobile surface) that opened the plan knows the owner exactly.
|
||||
*/
|
||||
export interface SavedProjectPlanTarget {
|
||||
projectRef: ProjectRef;
|
||||
planId: string;
|
||||
}
|
||||
|
||||
export const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
|
||||
export const PROJECT_TODO_TEXT_MAX_LENGTH = 120;
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ const skill = (name: string, path: string) => ({ name, path });
|
||||
const AGENTS = (name: string) => skill(name, `/repo/.agents/skills/${name}/SKILL.md`);
|
||||
const CLAUDE = (name: string) => skill(name, `/repo/.claude/skills/${name}/SKILL.md`);
|
||||
const OPENCODE = (name: string) => skill(name, `/home/u/.config/opencode/skill/${name}/SKILL.md`);
|
||||
const WIN_AGENTS = (name: string) => skill(name, String.raw`C:\Users\u\.agents\skills\${name}\SKILL.md`);
|
||||
const WIN_CLAUDE = (name: string) => skill(name, String.raw`C:\Users\u\.claude\skills\${name}\SKILL.md`);
|
||||
const WIN_OPENCODE = (name: string) => skill(name, String.raw`C:\Users\u\.config\opencode\skill\${name}\SKILL.md`);
|
||||
|
||||
const ENABLED = { claudeDisabled: false, allDisabled: false };
|
||||
|
||||
@@ -20,6 +23,13 @@ describe('resolveSkillRoot', () => {
|
||||
test('does not match a directory that merely contains the name', () => {
|
||||
expect(resolveSkillRoot('/repo/my.claude.backup/skills/a/SKILL.md')).toBe('opencode');
|
||||
});
|
||||
|
||||
test('classifies Windows backslash paths', () => {
|
||||
expect(resolveSkillRoot(WIN_CLAUDE('a').path)).toBe('claude');
|
||||
expect(resolveSkillRoot(WIN_AGENTS('a').path)).toBe('agents');
|
||||
expect(resolveSkillRoot(WIN_OPENCODE('a').path)).toBe('opencode');
|
||||
expect(resolveSkillRoot(String.raw`C:\repo\my.claude.backup\skills\a\SKILL.md`)).toBe('opencode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSkillsByRuntimeFlags', () => {
|
||||
@@ -72,4 +82,22 @@ describe('filterSkillsByRuntimeFlags', () => {
|
||||
const result = filterSkillsByRuntimeFlags([CLAUDE('only-claude'), AGENTS('other')], ENABLED);
|
||||
expect(result.map((s) => s.name).sort()).toEqual(['only-claude', 'other']);
|
||||
});
|
||||
|
||||
test('drops Windows .agents and .claude skills when external skills are disabled', () => {
|
||||
const skills = [WIN_AGENTS('a'), WIN_CLAUDE('b'), WIN_OPENCODE('c')];
|
||||
const result = filterSkillsByRuntimeFlags(skills, { claudeDisabled: false, allDisabled: true });
|
||||
expect(result.map((s) => s.name)).toEqual(['c']);
|
||||
});
|
||||
|
||||
test('drops only Windows .claude skills when claude skills are disabled', () => {
|
||||
const skills = [WIN_AGENTS('a'), WIN_CLAUDE('b'), WIN_OPENCODE('c')];
|
||||
const result = filterSkillsByRuntimeFlags(skills, { claudeDisabled: true, allDisabled: false });
|
||||
expect(result.map((s) => s.name).sort()).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
test('prefers the .agents copy for a duplicated name on Windows', () => {
|
||||
const result = filterSkillsByRuntimeFlags([WIN_CLAUDE('dup'), WIN_AGENTS('dup')], ENABLED);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toContain('.agents');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,8 +35,12 @@ const AGENTS_ROOT = /(^|\/)\.agents\//;
|
||||
type SkillRoot = 'claude' | 'agents' | 'opencode';
|
||||
|
||||
export const resolveSkillRoot = (skillPath: string): SkillRoot => {
|
||||
if (CLAUDE_ROOT.test(skillPath)) return 'claude';
|
||||
if (AGENTS_ROOT.test(skillPath)) return 'agents';
|
||||
// Server discovery joins paths with the platform separator, so Windows
|
||||
// skill paths arrive with backslashes. Normalize before matching the
|
||||
// root regexes, which are expressed with forward slashes.
|
||||
const normalized = skillPath.replace(/\\/g, '/');
|
||||
if (CLAUDE_ROOT.test(normalized)) return 'claude';
|
||||
if (AGENTS_ROOT.test(normalized)) return 'agents';
|
||||
return 'opencode';
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ interface MemoryReadResult {
|
||||
projectFailed: boolean;
|
||||
}
|
||||
|
||||
interface PendingMemoryRead {
|
||||
resolve?: (result: MemoryReadResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swappable implementations rather than mock helpers: each test states the one
|
||||
* behaviour it needs.
|
||||
@@ -45,7 +49,7 @@ mock.module('@/lib/agentMemoryApi', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { useAgentMemoryStore } = await import('./useAgentMemoryStore');
|
||||
const { selectProjectMemoryForPath, useAgentMemoryStore } = await import('./useAgentMemoryStore');
|
||||
|
||||
beforeEach(() => {
|
||||
useAgentMemoryStore.getState().reset();
|
||||
@@ -86,6 +90,38 @@ describe('load', () => {
|
||||
expect(state.error).toBe('offline');
|
||||
});
|
||||
|
||||
test("does not expose the previous project's memories under the Chats owner", async () => {
|
||||
await useAgentMemoryStore.getState().load('/workspace/openchamber');
|
||||
|
||||
const pending: PendingMemoryRead = {};
|
||||
readImpl = () => new Promise((resolve) => {
|
||||
pending.resolve = resolve;
|
||||
});
|
||||
const chatsPath = '/Users/test/.config/openchamber/chats';
|
||||
const loadingChats = useAgentMemoryStore.getState().load(chatsPath);
|
||||
|
||||
const switched = useAgentMemoryStore.getState();
|
||||
expect(selectProjectMemoryForPath(switched, chatsPath)).toEqual([]);
|
||||
expect(switched.projectPath).toBe(chatsPath);
|
||||
|
||||
pending.resolve?.({ global: [entry({ id: 'g1' })], project: [], globalFailed: false, projectFailed: false });
|
||||
await loadingChats;
|
||||
|
||||
expect(selectProjectMemoryForPath(useAgentMemoryStore.getState(), chatsPath)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a failed load for a new owner stays distinct from an empty project', async () => {
|
||||
await useAgentMemoryStore.getState().load('/workspace/openchamber');
|
||||
readImpl = async () => { throw new Error('offline'); };
|
||||
|
||||
await useAgentMemoryStore.getState().load('/Users/test/.config/openchamber/chats');
|
||||
|
||||
const state = useAgentMemoryStore.getState();
|
||||
expect(state.project).toEqual([]);
|
||||
expect(state.projectFailed).toBe(true);
|
||||
expect(state.error).toBe('offline');
|
||||
});
|
||||
|
||||
test('a disabled feature clears the lists rather than reporting an error', async () => {
|
||||
await useAgentMemoryStore.getState().load('/tmp/project');
|
||||
readImpl = async () => { throw new AgentMemoryDisabledError(); };
|
||||
|
||||
@@ -57,6 +57,14 @@ const EMPTY_STATE = {
|
||||
error: null as string | null,
|
||||
};
|
||||
|
||||
const EMPTY_MEMORY: AgentMemoryEntry[] = [];
|
||||
|
||||
/** Never expose one owner's project entries under another owner's heading. */
|
||||
export const selectProjectMemoryForPath = (
|
||||
state: AgentMemoryState,
|
||||
projectPath: string | null,
|
||||
): AgentMemoryEntry[] => state.projectPath === projectPath ? state.project : EMPTY_MEMORY;
|
||||
|
||||
/**
|
||||
* Only the newest load may write to the store. Turning the feature back on
|
||||
* fires a load before the setting has finished being written, so an older
|
||||
@@ -93,13 +101,20 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
|
||||
|
||||
load: async (projectPath) => {
|
||||
const requestId = ++loadSequence;
|
||||
set({ loading: true, projectPath });
|
||||
const previous = get();
|
||||
const ownerChanged = previous.projectPath !== projectPath;
|
||||
if (ownerChanged) {
|
||||
set({ loading: true, projectPath, project: [], projectFailed: false });
|
||||
} else {
|
||||
set({ loading: true, projectPath });
|
||||
}
|
||||
try {
|
||||
const snapshot = await fetchAgentMemory(projectPath);
|
||||
if (requestId !== loadSequence) return;
|
||||
const current = get();
|
||||
set({
|
||||
global: snapshot.global,
|
||||
project: snapshot.project,
|
||||
global: snapshot.globalFailed ? current.global : snapshot.global,
|
||||
project: snapshot.projectFailed ? current.project : snapshot.project,
|
||||
projectPath,
|
||||
globalFailed: snapshot.globalFailed,
|
||||
projectFailed: snapshot.projectFailed,
|
||||
@@ -119,7 +134,12 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
// Whatever was loaded before stays. Only the error is new.
|
||||
set({ loading: false, error: errorMessage(error, 'Failed to load agent memory') });
|
||||
set({
|
||||
loading: false,
|
||||
globalFailed: true,
|
||||
projectFailed: true,
|
||||
error: errorMessage(error, 'Failed to load agent memory'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -156,4 +176,3 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
|
||||
set({ ...EMPTY_STATE });
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -28,6 +28,183 @@ describe('useUIStore context panel tabs', () => {
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps a plan tab that carries its owning project', () => {
|
||||
const directory = '/repo';
|
||||
const projectRef = { id: 'proj_1', path: '/repo' };
|
||||
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: 'plan-1',
|
||||
projectPlanRef: projectRef,
|
||||
dedupeKey: `plan:${projectRef.id}:plan-1`,
|
||||
label: 'My plan',
|
||||
});
|
||||
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.projectPlanId).toBe('plan-1');
|
||||
expect(tabs[0]?.projectPlanRef).toEqual(projectRef);
|
||||
});
|
||||
|
||||
test('dedupes plan tabs by owner and plan id, not by plan id alone', () => {
|
||||
const directory = '/repo';
|
||||
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: 'plan-1',
|
||||
projectPlanRef: { id: 'proj_1', path: '/repo' },
|
||||
dedupeKey: 'plan:proj_1:plan-1',
|
||||
});
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: 'plan-1',
|
||||
projectPlanRef: { id: 'proj_1', path: '/repo' },
|
||||
dedupeKey: 'plan:proj_1:plan-1',
|
||||
});
|
||||
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
expect(tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('drops persisted plan tabs whose owner is missing instead of guessing it', () => {
|
||||
const directory = '/repo';
|
||||
const persisted = {
|
||||
contextPanelByDirectory: {
|
||||
[directory]: {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
widthByMode: {},
|
||||
touchedAt: 1,
|
||||
activeTabId: 'plan:plan-1',
|
||||
tabs: [
|
||||
// Pre-owner tab: has an id but no projectPlanRef.
|
||||
{
|
||||
id: 'plan:plan-1',
|
||||
mode: 'plan',
|
||||
targetPath: null,
|
||||
projectPlanId: 'plan-1',
|
||||
projectPlanRef: null,
|
||||
dedupeKey: 'plan:plan-1',
|
||||
label: 'Old plan',
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: null,
|
||||
touchedAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// SAFETY: the object mirrors the persisted context-panel shape exactly;
|
||||
// setState bypasses the persist middleware's typing, not its migration.
|
||||
useUIStore.setState(persisted as never);
|
||||
// Sanitization runs whenever panel state is touched; opening a valid tab
|
||||
// is the ordinary touch that would flush stale persisted tabs out.
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: 'plan-2',
|
||||
projectPlanRef: { id: 'proj_1', path: '/repo' },
|
||||
dedupeKey: 'plan:proj_1:plan-2',
|
||||
});
|
||||
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.projectPlanId).toBe('plan-2');
|
||||
});
|
||||
|
||||
test('keeps a generic filesystem plan tab that has no saved-plan identity', () => {
|
||||
const directory = '/repo';
|
||||
useUIStore.getState().openContextSurface(directory, 'plan');
|
||||
// A later touch runs the same sanitizer rehydrate uses.
|
||||
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
||||
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
const planTab = tabs.find((tab) => tab.mode === 'plan');
|
||||
expect(planTab).toBeDefined();
|
||||
expect(planTab?.projectPlanId).toBeNull();
|
||||
expect(planTab?.projectPlanRef).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps a persisted generic plan tab through rehydration-like touches', () => {
|
||||
const directory = '/repo';
|
||||
const persisted = {
|
||||
contextPanelByDirectory: {
|
||||
[directory]: {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
widthByMode: {},
|
||||
touchedAt: 1,
|
||||
activeTabId: 'plan',
|
||||
tabs: [
|
||||
{
|
||||
id: 'plan',
|
||||
mode: 'plan',
|
||||
targetPath: null,
|
||||
projectPlanId: null,
|
||||
projectPlanRef: null,
|
||||
dedupeKey: 'plan',
|
||||
label: 'Plan',
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: null,
|
||||
touchedAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// SAFETY: the object mirrors the persisted context-panel shape exactly;
|
||||
// setState bypasses the persist middleware's typing, not its migration.
|
||||
useUIStore.setState(persisted as never);
|
||||
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
||||
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true);
|
||||
});
|
||||
|
||||
test('drops a persisted saved-plan tab carrying an owner but no plan id', () => {
|
||||
const directory = '/repo';
|
||||
const persisted = {
|
||||
contextPanelByDirectory: {
|
||||
[directory]: {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
widthByMode: {},
|
||||
touchedAt: 1,
|
||||
activeTabId: null,
|
||||
tabs: [
|
||||
{
|
||||
id: 'plan:proj_1:plan-1',
|
||||
mode: 'plan',
|
||||
targetPath: null,
|
||||
projectPlanId: null,
|
||||
projectPlanRef: { id: 'proj_1', path: '/repo' },
|
||||
dedupeKey: 'plan:proj_1:plan-1',
|
||||
label: 'Half-identified',
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: null,
|
||||
touchedAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// SAFETY: the object mirrors the persisted context-panel shape exactly;
|
||||
// setState bypasses the persist middleware's typing, not its migration.
|
||||
useUIStore.setState(persisted as never);
|
||||
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
||||
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUIStore openContextSurface', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
|
||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import type { TerminalShell } from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
|
||||
import { isWindowsArm64 } from '@/lib/platform';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -37,6 +38,10 @@ type ContextPanelTab = {
|
||||
panel. Project plans are addressed by id because their markdown is
|
||||
server-owned and has no client-visible path. */
|
||||
projectPlanId: string | null;
|
||||
/** The project that owns `projectPlanId`. Persisted with the tab so a
|
||||
restored plan tab opens against its own project instead of guessing the
|
||||
owner from whatever directory happens to be current. */
|
||||
projectPlanRef: ProjectRef | null;
|
||||
dedupeKey: string;
|
||||
label: string | null;
|
||||
sessionTitleFallback: string | null;
|
||||
@@ -50,6 +55,7 @@ type ContextPanelTabDescriptor = {
|
||||
mode: ContextPanelMode;
|
||||
targetPath?: string | null;
|
||||
projectPlanId?: string | null;
|
||||
projectPlanRef?: ProjectRef | null;
|
||||
dedupeKey?: string | null;
|
||||
label?: string | null;
|
||||
sessionTitleFallback?: string | null;
|
||||
@@ -191,6 +197,18 @@ const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
|
||||
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
|
||||
};
|
||||
|
||||
/** A plan tab's owner must be a complete project reference or nothing; a
|
||||
half-valid one is worse than none because it points the editor somewhere. */
|
||||
const normalizeContextPanelProjectPlanRef = (value: unknown): ProjectRef | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as { id?: unknown; path?: unknown };
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const path = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||
return id && path ? { id, path } : null;
|
||||
};
|
||||
|
||||
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
|
||||
if (mode === 'file') {
|
||||
return targetPath || mode;
|
||||
@@ -240,6 +258,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
|
||||
projectPlanId: typeof descriptor.projectPlanId === 'string' && descriptor.projectPlanId.trim()
|
||||
? descriptor.projectPlanId.trim()
|
||||
: null,
|
||||
projectPlanRef: normalizeContextPanelProjectPlanRef(descriptor.projectPlanRef),
|
||||
dedupeKey,
|
||||
label: normalizeContextTabLabel(descriptor.label),
|
||||
sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback),
|
||||
@@ -300,6 +319,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
projectPlanId?: unknown;
|
||||
projectPlanRef?: unknown;
|
||||
dedupeKey?: unknown;
|
||||
label?: unknown;
|
||||
sessionTitleFallback?: unknown;
|
||||
@@ -323,6 +343,19 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
}
|
||||
|
||||
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
|
||||
const projectPlanId = typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
|
||||
? candidate.projectPlanId.trim()
|
||||
: null;
|
||||
const projectPlanRef = normalizeContextPanelProjectPlanRef(candidate.projectPlanRef);
|
||||
// `mode: 'plan'` covers two documents: a saved Project knowledge plan
|
||||
// (needs both the plan id and its owning project) and a plain session
|
||||
// filesystem plan (has neither). Only the half-identified form — id
|
||||
// without owner — is unopenable: the editor would have to guess the
|
||||
// project from the current directory, which is exactly the bug that made
|
||||
// saved plans open empty. Such tabs are dropped rather than resurrected.
|
||||
if (candidate.mode === 'plan' && (projectPlanId !== null) !== (projectPlanRef !== null)) {
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = normalizeContextPanelTabDedupeKey(
|
||||
candidate.mode,
|
||||
targetPath,
|
||||
@@ -338,9 +371,8 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
id,
|
||||
mode: candidate.mode,
|
||||
targetPath,
|
||||
projectPlanId: typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
|
||||
? candidate.projectPlanId.trim()
|
||||
: null,
|
||||
projectPlanId,
|
||||
projectPlanRef,
|
||||
dedupeKey,
|
||||
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
|
||||
sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null),
|
||||
@@ -405,20 +437,22 @@ const upsertContextPanelTab = (
|
||||
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
|
||||
const tabs = existingIndex === -1
|
||||
? [...baseTabs, nextTab]
|
||||
: baseTabs.map((tab, index) => (index === existingIndex
|
||||
? {
|
||||
...tab,
|
||||
mode: nextTab.mode,
|
||||
targetPath: nextTab.targetPath || tab.targetPath,
|
||||
dedupeKey: nextTab.dedupeKey,
|
||||
label: nextTab.label,
|
||||
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
|
||||
stagedDiff: nextTab.stagedDiff,
|
||||
diffScope: nextTab.diffScope,
|
||||
readOnly: nextTab.readOnly,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
: tab));
|
||||
: baseTabs.map((tab, index) => (index === existingIndex
|
||||
? {
|
||||
...tab,
|
||||
mode: nextTab.mode,
|
||||
targetPath: nextTab.targetPath || tab.targetPath,
|
||||
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
|
||||
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
|
||||
dedupeKey: nextTab.dedupeKey,
|
||||
label: nextTab.label,
|
||||
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
|
||||
stagedDiff: nextTab.stagedDiff,
|
||||
diffScope: nextTab.diffScope,
|
||||
readOnly: nextTab.readOnly,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
: tab));
|
||||
|
||||
// A background upsert (an agent working a page) keeps the panel exactly as
|
||||
// the user left it: closed stays closed, and whatever tab they were on
|
||||
@@ -545,6 +579,10 @@ const sanitizeContextPanelByDirectory = (
|
||||
let tabs = sanitizeContextPanelTabs(candidate.tabs);
|
||||
let activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null;
|
||||
|
||||
// Legacy single-tab state can name a saved project plan, but it carries
|
||||
// no owner and cannot be migrated into an openable saved-plan tab — that
|
||||
// combination is dropped by sanitize above. A generic filesystem plan tab
|
||||
// (no plan id) revives fine from the descriptor alone.
|
||||
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
|
||||
tabs = [createContextPanelTab({
|
||||
mode: candidate.mode,
|
||||
|
||||
@@ -996,7 +996,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// skeleton to render and reads messages which can be expensive.
|
||||
if (previousSessionId && previousSessionId !== id) {
|
||||
const prevId = previousSessionId
|
||||
setTimeout(() => {
|
||||
const newId = id
|
||||
// queueMicrotask runs after the current synchronous call stack (and
|
||||
// before the next macrotask / setTimeout(0) / paint), so the previous
|
||||
// session's anchor is saved before the new session's restoreSnapshot
|
||||
// effect fires. This eliminates the race where save and restore
|
||||
// interleave against the same viewport store entry.
|
||||
queueMicrotask(() => {
|
||||
// Bail if the user already switched again — save is now stale.
|
||||
const current = get().currentSessionId
|
||||
if (current !== newId) return
|
||||
const memState = getViewportSessionMemory(prevId)
|
||||
if (!memState?.isStreaming) {
|
||||
const prevMessages = getSyncMessages(prevId)
|
||||
@@ -1004,7 +1013,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1)
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
});
|
||||
}
|
||||
|
||||
// Mark session viewed in notification store + update active session ref
|
||||
|
||||
Reference in New Issue
Block a user