Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/lib/api/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ describe('chat API (browser mock)', () => {
await rejected;
});

it('createConversation drops a cyclic click-event cwd instead of throwing', async () => {
const cyclic: { target?: unknown } = {};
cyclic.target = cyclic;
expect(() => JSON.stringify(cyclic)).toThrow(/circular|cyclic/i);
const createP = createConversation(['claude'], cyclic as unknown as string);
await vi.runAllTimersAsync();
const created = await createP;
expect(created.agentIds).toEqual(['claude']);
expect(created.cwd).toBeNull();
});

it('ensureDefaultConversation reuses the initial blank conversation', async () => {
const firstP = ensureDefaultConversation(['claude']);
await vi.runAllTimersAsync();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Chat API façade — delegates to app runtime backend.
*/
import { getBackend } from '@/app/runtime';
import { createConversationCwd } from '@/lib/open-chat-cwd';
import type { AgentKey, ChatEvent, ChatHistoryTurn, ChatMessage, Conversation } from '@/lib/types';
import type { MarkdownFilePreviewDto } from '@/lib/backend/contracts/chat-port';
import type { RuntimeOptions, RuntimeReply, RuntimeSnapshot, RuntimeStartExtras, RuntimeTurnSettings } from '@/lib/backend/contracts/chat-runtime';
Expand All @@ -23,7 +24,7 @@ export async function createConversation(
agentIds: AgentKey[],
cwd?: string | null,
): Promise<Conversation> {
return getBackend().chat.createConversation(agentIds, cwd);
return getBackend().chat.createConversation(agentIds, createConversationCwd(cwd));
}

export async function ensureDefaultConversation(
Expand Down
3 changes: 2 additions & 1 deletion src/lib/backend/tauri/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChatPort, MarkdownFilePreviewDto } from '@/lib/backend/contracts';
import { createConversationCwd } from '@/lib/open-chat-cwd';
import {
mapChatMessage,
mapConversation,
Expand All @@ -20,7 +21,7 @@ export function createTauriChatPort(): ChatPort {
async createConversation(agentIds, cwd) {
const row = await invoke<CoreConversation>('create_conversation', {
agentIds,
cwd: cwd ?? null,
cwd: createConversationCwd(cwd),
});
return mapConversation(row);
},
Expand Down
30 changes: 30 additions & 0 deletions src/lib/open-chat-cwd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import {
consumePendingOpenChatCwd,
createConversationCwd,
folderNameFromCwd,
newChatCwdArg,
shellOpenChatBootstrap,
shellOpenChatHref,
} from './open-chat-cwd';
Expand All @@ -26,6 +28,25 @@ describe('folderNameFromCwd', () => {
});
});

describe('newChatCwdArg', () => {
it('keeps a folder path or explicit null and drops click events', () => {
expect(newChatCwdArg('/workspace')).toBe('/workspace');
expect(newChatCwdArg(null)).toBeNull();
expect(newChatCwdArg(undefined)).toBeUndefined();
const cyclic: { target?: unknown } = {};
cyclic.target = cyclic;
expect(() => JSON.stringify(cyclic)).toThrow(/circular|cyclic/i);
expect(newChatCwdArg(cyclic)).toBeUndefined();
expect(() => JSON.stringify({
agentIds: ['grok'],
cwd: createConversationCwd(cyclic),
})).not.toThrow();
expect(createConversationCwd(cyclic)).toBeNull();
expect(createConversationCwd('/workspace')).toBe('/workspace');
expect(createConversationCwd(null)).toBeNull();
});
});

describe('consumePendingOpenChatCwd', () => {
it('is a no-op when takePending returns nothing', async () => {
const applyBootstrap = vi.fn();
Expand Down Expand Up @@ -85,4 +106,13 @@ describe('App open-chat wiring', () => {
/HashRouter `useNavigate` changes identity with pathname; do not resubscribe\.\s*\n\s*\}, \[\]\);/,
);
});

it('omits non-string cwd before create-conversation persist and IPC', () => {
const dir = path.dirname(fileURLToPath(import.meta.url));
const api = readFileSync(path.resolve(dir, 'api/chat.ts'), 'utf8');
const tauri = readFileSync(path.resolve(dir, 'backend/tauri/chat.ts'), 'utf8');
expect(api).toContain('createConversationCwd');
expect(tauri).toContain('createConversationCwd');
expect(tauri).toContain('cwd: createConversationCwd(cwd)');
});
});
16 changes: 16 additions & 0 deletions src/lib/open-chat-cwd.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import type { ChatBootstrap } from '@/lib/types';

/**
* New-chat cwd for persist / IPC. Only a folder path or explicit null.
* Click events and other objects are dropped so JSON.stringify never sees a cycle.
*/
export function newChatCwdArg(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === 'string') return value;
return undefined;
}

/** Wire / invoke shape: never pass a non-string through JSON.stringify. */
export function createConversationCwd(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}

/** Last path segment for a folder chosen in the OS file manager. */
export function folderNameFromCwd(cwd: string): string {
const trimmed = cwd.trim().replace(/[\\/]+$/, '');
Expand Down
25 changes: 20 additions & 5 deletions src/pages/chat/ChatOutlineRail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,21 @@ describe('ChatOutlineRail visibility gates', () => {
{ turn: 2, agents: [] },
{ turn: 3, agents: [] },
];
expect(hasOutline(renderRail({ turns: oneUser, measuredWidth: 800, enabled: true }))).toBe(false);
expect(renderRail({ turns: oneUser, measuredWidth: 800, enabled: true })).toBe('');
const html = renderRail({ turns: oneUser, measuredWidth: 800, enabled: true });
expect(hasOutline(html)).toBe(false);
expect(html).toContain('data-chat-outline-measure');
expect(html).not.toContain('role="tablist"');
});

it('returns nothing when the setting is off or there are fewer than two user messages', () => {
it('returns nothing when the setting is off, and keeps a measure host for one user message', () => {
expect(renderRail({
turns: turns('first', 'second'),
measuredWidth: 800,
enabled: false,
})).toBe('');
expect(renderRail({ turns: turns('only one'), measuredWidth: 800 })).toBe('');
const one = renderRail({ turns: turns('only one'), measuredWidth: 800 });
expect(hasOutline(one)).toBe(false);
expect(one).toContain('data-chat-outline-measure');
});

describe('stored preference when enabled is omitted', () => {
Expand Down Expand Up @@ -139,8 +143,9 @@ describe('ChatOutlineRail visibility gates', () => {
});

describe('ChatOutlineRail markup', () => {
it('does not draw a rail for one user message', () => {
it('keeps the measure host mounted for one user message so width can attach', () => {
const html = renderRail({ turns: turns('only one'), measuredWidth: 800 });
expect(html).toContain('data-chat-outline-measure');
expect(html).not.toContain('chat-outline-rail');
expect(html).not.toContain('role="tablist"');
});
Expand All @@ -150,12 +155,22 @@ describe('ChatOutlineRail markup', () => {
expect(html).toContain('data-testid="chat-outline-rail"');
expect(html).toContain('role="tablist"');
expect(html).toContain('role="tab"');
expect(html).toContain('type="button"');
expect(html).toContain('data-testid="chat-outline-tick-u1"');
expect(html).toContain('data-testid="chat-outline-tick-u2"');
expect(html).toContain('1 / 2:first');
expect(html).toContain('2 / 2:second');
});

it('does not treat a zero-width empty host as a mounted rail', () => {
const html = renderRail({ turns: turns('first', 'second'), measuredWidth: 0 });
expect(html).toContain('data-chat-outline-measure');
expect(html).not.toContain('chat-outline-rail');
expect(html).not.toContain('role="tablist"');
expect(html).not.toContain('role="tab"');
expect(html).not.toContain('chat-outline-tick-');
});

it('hides the rail when the panel is narrower than 720px', () => {
const html = renderRail({ turns: turns('first', 'second'), measuredWidth: 719 });
expect(html).not.toContain('chat-outline-rail');
Expand Down
41 changes: 29 additions & 12 deletions src/pages/chat/ChatOutlineRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type PointerEvent,
Expand All @@ -16,9 +15,12 @@ import type { TurnGroup } from './chat-format';
import { createChatOutlineHoverIntent } from './chat-outline-hover';
import {
OUTLINE_READING_LINE_PX,
outlinePanelElement,
outlinePanelWidthReady,
outlinePromptsFromTurns,
outlineTickSize,
promptTickMagnification,
readOutlinePanelWidth,
resolveActivePromptId,
shouldShowChatOutline,
type ChatOutlinePrompt,
Expand Down Expand Up @@ -47,8 +49,12 @@ export function ChatOutlineRail({
const [prefEnabled] = useState(loadChatOutlineEnabled);
const isEnabled = enabled ?? prefEnabled;
const [observedWidth, setObservedWidth] = useState(0);
const panelWidth = measuredWidth ?? observedWidth;
const measureRef = useRef<HTMLDivElement>(null);
const hasMeasuredWidth = outlinePanelWidthReady(measuredWidth);
const panelWidth = hasMeasuredWidth ? measuredWidth : observedWidth;
const [measureNode, setMeasureNode] = useState<HTMLDivElement | null>(null);
const assignMeasureRef = useCallback((node: HTMLDivElement | null) => {
setMeasureNode((prev) => (prev === node ? prev : node));
}, []);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
Expand All @@ -67,15 +73,22 @@ export function ChatOutlineRail({
useEffect(() => () => hoverIntent.dispose(), [hoverIntent]);

useEffect(() => {
if (measuredWidth != null) return;
const node = measureRef.current;
if (!node || typeof ResizeObserver === 'undefined') return;
const apply = () => setObservedWidth(node.getBoundingClientRect().width);
const observer = new ResizeObserver(apply);
observer.observe(node);
if (hasMeasuredWidth) return;
const node = measureNode;
if (!node) return;
const apply = () => {
const next = readOutlinePanelWidth(node);
setObservedWidth((prev) => (prev === next ? prev : next));
};
apply();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', apply);
return () => window.removeEventListener('resize', apply);
}
const observer = new ResizeObserver(apply);
observer.observe(outlinePanelElement(node) ?? node);
return () => observer.disconnect();
}, [measuredWidth]);
}, [hasMeasuredWidth, measureNode]);

const readActivePrompt = useCallback(() => {
const container = scrollRef?.current;
Expand Down Expand Up @@ -134,10 +147,14 @@ export function ChatOutlineRail({
if (!visible) hoverIntent.leave();
}, [hoverIntent, visible]);

if (!isEnabled || prompts.length < 2) return null;
if (!isEnabled) return null;

return (
<div ref={measureRef} className="pointer-events-none absolute inset-0">
<div
ref={assignMeasureRef}
className="pointer-events-none absolute inset-0"
data-chat-outline-measure
>
{visible ? (
<div
role="tablist"
Expand Down
6 changes: 6 additions & 0 deletions src/pages/chat/ChatSessionRail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,11 @@ describe('ChatSessionRail titles', () => {
const unsetGroup = html.slice(html.lastIndexOf('data-help="chat-workspace-group"', unsetAt), unsetAt);
expect(unsetGroup).not.toContain('data-help="chat-workspace-new"');
});

it('starts the main new chat without passing the click event as a folder', () => {
const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8');
expect(src).toContain('onClick={() => onNewChat()}');
expect(src).not.toContain('onClick={onNewChat}');
});
});

88 changes: 85 additions & 3 deletions src/pages/chat/ChatTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ function conversation(): Conversation {
};
}

function userMessage(content: string): ChatMessage {
function userMessage(content: string, id = 'm-user', turn = 1): ChatMessage {
return {
id: 'm-user',
id,
conversationId: 'c1',
turn: 1,
turn,
role: 'user',
content,
status: 'ok',
Expand Down Expand Up @@ -192,4 +192,86 @@ describe('ChatTranscript surfaces', () => {
// Unmeasured panel width is 0, so the 720px gate still hides the ticks.
expect(html).not.toContain('data-testid="chat-outline-rail"');
});

it('mounts the outline rail when the setting, two prompts, and a 720px panel hold', () => {
const html = renderMarkup(
createElement(ChatTranscript, {
active: conversation(),
turns: [
{ turn: 1, user: userMessage('first prompt', 'u1', 1), agents: [] },
{ turn: 2, user: userMessage('second prompt', 'u2', 2), agents: [] },
],
processMap: {},
listLoading: false,
messagesLoading: false,
sending: false,
retryDisabled: false,
scrollRef: createRef<HTMLDivElement>(),
bottomRef: createRef<HTMLDivElement>(),
onScroll: () => undefined,
onRetry: () => undefined,
measuredWidth: 720,
outlineEnabled: true,
}),
);
expect(html).toContain('data-testid="chat-outline-rail"');
expect(html).toContain('role="tablist"');
expect(html).toContain('role="tab"');
expect(html).toContain('type="button"');
expect(html).toContain('data-testid="chat-outline-tick-u1"');
expect(html).toContain('data-testid="chat-outline-tick-u2"');
expect(html).toContain('1 / 2:first prompt');
expect(html).toContain('2 / 2:second prompt');
expect(html).toContain('data-chat-outline-host');
});

it('does not count an empty measure host as the outline', () => {
const html = renderMarkup(
createElement(ChatTranscript, {
active: conversation(),
turns: [
{ turn: 1, user: userMessage('first prompt', 'u1', 1), agents: [] },
{ turn: 2, user: userMessage('second prompt', 'u2', 2), agents: [] },
],
processMap: {},
listLoading: false,
messagesLoading: false,
sending: false,
retryDisabled: false,
scrollRef: createRef<HTMLDivElement>(),
bottomRef: createRef<HTMLDivElement>(),
onScroll: () => undefined,
onRetry: () => undefined,
measuredWidth: 0,
outlineEnabled: true,
}),
);
expect(html).toContain('data-chat-outline-host');
expect(html).toContain('data-chat-outline-measure');
expect(html).not.toContain('chat-outline-rail');
expect(html).not.toContain('role="tablist"');
expect(html).not.toContain('chat-outline-tick-');
});

it('does not mount the rail ticks when only one user message exists', () => {
const html = renderMarkup(
createElement(ChatTranscript, {
active: conversation(),
turns: [{ turn: 1, user: userMessage('only one', 'u1'), agents: [] }],
processMap: {},
listLoading: false,
messagesLoading: false,
sending: false,
retryDisabled: false,
scrollRef: createRef<HTMLDivElement>(),
bottomRef: createRef<HTMLDivElement>(),
onScroll: () => undefined,
onRetry: () => undefined,
measuredWidth: 900,
outlineEnabled: true,
}),
);
expect(html).toContain('data-chat-outline-host');
expect(html).not.toContain('chat-outline-rail');
});
});
Loading
Loading