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
43 changes: 42 additions & 1 deletion apps/cockpit/src/app/cockpit.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,16 @@ pre.shiki {
font-size: 0.75rem;
}

/* Shared prose layer — docs + api */
/* Shared prose layer — docs + api + code mode content */
.cockpit-prose {
max-width: 42rem;
margin-inline: auto;
font-size: 0.9rem;
line-height: 1.7;
color: var(--ds-text-secondary);
}
.cockpit-prose--wide { max-width: 48rem; }
.cockpit-prose--code { max-width: 56rem; }
.cockpit-prose h1, .cockpit-prose h2, .cockpit-prose h3 {
font-family: var(--font-garamond), var(--ds-font-serif);
color: var(--ds-text-primary);
Expand All @@ -231,3 +234,41 @@ pre.shiki {
.cockpit-prose table.params { border-collapse: collapse; margin: 0.5rem 0; }
.cockpit-prose table.params th { font-family: var(--font-mono), monospace; font-size: 0.6rem; letter-spacing: 0.06em; text-transform: uppercase; padding-bottom: 0.5rem; border-bottom: 1px solid var(--ds-border); }
.cockpit-prose table.params td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid var(--ds-border); }

/* Code-mode file tree */
.cockpit-file-tree { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.7; }
.cockpit-file-tree ul { list-style: none; padding: 0; margin: 0; }
.cockpit-file-tree__file,
.cockpit-file-tree__folder {
display: flex; align-items: center; gap: 0.4rem; flex: 1; min-width: 0;
padding: 3px 0.75rem 3px 0.75rem; background: transparent; border: 0; text-align: left; cursor: pointer;
color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; font-size: 12px;
border-left: 2px solid transparent; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cockpit-file-tree__folder { color: var(--ds-text-muted); display: flex; align-items: center; }
.cockpit-file-tree__caret { font-size: 9px; color: var(--ds-text-muted); width: 0.65rem; flex-shrink: 0; }
.cockpit-file-tree__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cockpit-file-tree__chip {
font-family: var(--font-mono), monospace; font-size: 9px;
padding: 1px 5px; border-radius: 3px; margin-right: 0.5rem; flex-shrink: 0;
background: var(--ds-accent-surface); color: var(--ds-accent);
opacity: 0.85;
}
.cockpit-file-tree__file:hover { color: var(--ds-text-primary); }
.cockpit-file-tree__file[aria-current="true"] {
background: var(--ds-accent-surface);
color: var(--ds-text-primary);
border-left-color: var(--ds-accent);
}

/* Tab close (×) on Code-mode tabs */
.cockpit-tab-trigger { display: inline-flex; align-items: center; gap: 0.4rem; }
.cockpit-tab-trigger__close {
display: inline-flex; align-items: center; justify-content: center;
width: 0.95rem; height: 0.95rem; border-radius: 0.2rem;
color: var(--ds-text-muted); font-size: 0.85rem; line-height: 1;
opacity: 0; cursor: pointer;
}
.cockpit-tab-trigger:hover .cockpit-tab-trigger__close,
.cockpit-tab-trigger[data-state="active"] .cockpit-tab-trigger__close { opacity: 1; }
.cockpit-tab-trigger__close:hover { background: var(--ds-accent-surface); color: var(--ds-text-primary); }
4 changes: 2 additions & 2 deletions apps/cockpit/src/components/api-mode/api-mode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ export function ApiMode({ docSections, hasCodeFiles = false }: ApiModeProps) {
const pySections = docSections.filter((s) => s.language === 'python');

return (
<section aria-label="API mode" className="h-full overflow-auto py-4 px-4 md:px-8">
<div className="cockpit-prose" style={{ maxWidth: '48rem' }}>
<section aria-label="API mode" className="h-full overflow-auto py-6 px-4 md:px-8">
<div className="cockpit-prose cockpit-prose--wide">
{tsSections.length > 0 ? (
<div>
<h3
Expand Down
171 changes: 168 additions & 3 deletions apps/cockpit/src/components/code-mode/code-mode.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('CodeMode', () => {
expect(container.textContent).toContain('export default function Page() {}');

const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
expect(tabs.map((tab) => tab.textContent)).toEqual(['page.tsx', 'index.ts']);
expect(tabs.map((tab) => (tab.textContent ?? '').replace(/×/g, '').trim())).toEqual(['page.tsx', 'index.ts']);

act(() => {
(tabs[1] as HTMLElement).dispatchEvent(
Expand Down Expand Up @@ -105,12 +105,12 @@ describe('CodeMode', () => {
});

const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
const tabLabels = tabs.map((tab) => tab.textContent);
const tabLabels = tabs.map((tab) => (tab.textContent ?? '').replace(/×/g, '').trim());
expect(tabLabels).toContain('app.tsx');
expect(tabLabels).toContain('system.md');

act(() => {
const promptTab = tabs.find((tab) => tab.textContent === 'system.md') as HTMLElement;
const promptTab = tabs.find((tab) => (tab.textContent ?? '').replace(/×/g, '').trim() === 'system.md') as HTMLElement;
promptTab.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
);
Expand All @@ -119,6 +119,171 @@ describe('CodeMode', () => {
expect(container.textContent).toContain('You are a helpful assistant.');
});

it('pre-opens all code, backend, and prompt files as tabs with the first code file active', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root!.render(
<CodeMode
entryTitle="Planning"
codeAssetPaths={['src/a.ts']}
backendAssetPaths={['backend/graph.py']}
codeFiles={{
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
'backend/graph.py': '<pre class="shiki"><code>g</code></pre>',
}}
promptFiles={{ 'prompts/p.md': 'hello' }}
/>,
);
});

const tabLabels = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => (t.textContent ?? '').replace(/×/g, '').trim());
expect(tabLabels).toEqual(['a.ts', 'graph.py', 'p.md']);

const active = container.querySelector('[role="tab"][data-state="active"]');
expect((active?.textContent ?? '').replace(/×/g, '').trim()).toBe('a.ts');
});

it('opens a closed file and activates it when the tree row is clicked', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root!.render(
<CodeMode
entryTitle="Planning"
codeAssetPaths={['src/a.ts', 'src/b.ts']}
backendAssetPaths={[]}
codeFiles={{
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
'src/b.ts': '<pre class="shiki"><code>b</code></pre>',
}}
promptFiles={{}}
/>,
);
});

// Locate the tree row for b.ts and click it. Since FT5 has both files pre-opened,
// this verifies the tree-click path even before FT6 introduces close behaviour.
const bRow = Array.from(container.querySelectorAll('[data-file-row]')).find(
(el) => el.querySelector('[data-file-label]')?.textContent === 'b.ts',
) as HTMLElement;
expect(bRow).toBeDefined();

act(() => { bRow.click(); });

const active = container.querySelector('[role="tab"][data-state="active"]');
expect((active?.textContent ?? '').replace(/×/g, '').trim()).toBe('b.ts');
});

it('closes a tab and activates its left neighbor', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root!.render(
<CodeMode
entryTitle="Planning"
codeAssetPaths={['src/a.ts', 'src/b.ts', 'src/c.ts']}
backendAssetPaths={[]}
codeFiles={{
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
'src/b.ts': '<pre class="shiki"><code>b</code></pre>',
'src/c.ts': '<pre class="shiki"><code>c</code></pre>',
}}
promptFiles={{}}
/>,
);
});

// Activate b.ts, then close it.
const bTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.startsWith('b.ts'),
) as HTMLElement;
act(() => {
bTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
});

const closeBtn = container.querySelector('[role="tab"][data-state="active"] [data-tab-close]') as HTMLElement;
expect(closeBtn).not.toBeNull();
act(() => { closeBtn.click(); });

const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) =>
(t.textContent ?? '').replace(/×/g, '').trim(),
);
expect(tabs).toEqual(['a.ts', 'c.ts']);

const active = container.querySelector('[role="tab"][data-state="active"]');
expect((active?.textContent ?? '').startsWith('a.ts')).toBe(true);
});

it('shows the empty state after the last tab is closed', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root!.render(
<CodeMode
entryTitle="Planning"
codeAssetPaths={['src/only.ts']}
backendAssetPaths={[]}
codeFiles={{ 'src/only.ts': '<pre class="shiki"><code>x</code></pre>' }}
promptFiles={{}}
/>,
);
});

const closeBtn = container.querySelector('[role="tab"] [data-tab-close]') as HTMLElement;
act(() => { closeBtn.click(); });

expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0);
expect(container.textContent).toContain('Select a file from the tree');
});

it('closes a tab when Enter is pressed on the close button', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root!.render(
<CodeMode
entryTitle="Planning"
codeAssetPaths={['src/a.ts', 'src/b.ts']}
backendAssetPaths={[]}
codeFiles={{
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
'src/b.ts': '<pre class="shiki"><code>b</code></pre>',
}}
promptFiles={{}}
/>,
);
});

// The first tab (a.ts) is active by default; its close span is focusable.
const closeBtn = container.querySelector(
'[role="tab"][data-state="active"] [data-tab-close]',
) as HTMLElement;
expect(closeBtn).not.toBeNull();
expect(closeBtn.getAttribute('tabindex')).toBe('0');

act(() => {
closeBtn.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});

const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) =>
(t.textContent ?? '').replace(/×/g, '').trim(),
);
expect(tabs).toEqual(['b.ts']);
});

it('fires cockpit:code_copied when the Copy button is clicked', () => {
container = document.createElement('div');
document.body.appendChild(container);
Expand Down
Loading
Loading