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
18 changes: 16 additions & 2 deletions docs/chat-console/chat-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ appears the way you left it straight away, instead of starting as the strip and
jumping open a moment later. Your saved preference still has the last word: if you folded
the rail on another device, it folds here too as soon as the preference loads.

On a tablet, the rail button in the chat's header expands or folds the rail.
On a tablet, the rail button in the chat's header expands or folds the rail. Its name
changes to say which it will do next ("Expand chat rail" or "Collapse chat rail"), and
screen readers are told whether the rail is open.

When you expand or fold the rail from the keyboard, focus stays in the rail. The button you
pressed is replaced, so focus moves to the one that took its place: the tab you asked for,
the collapse button, or the strip's expand button. Opening a file from Files puts focus on
the Preview tab, and closing the preview puts it back on the rail.

### On a phone

Expand Down Expand Up @@ -121,6 +128,13 @@ The most recently changed file is at the top. Edits made while a reply is still
appear as they happen. An edit that failed or was refused is not listed, and neither is a
write that left the file exactly as it was. Edits made inside a sub-agent are not listed.

A file the agent **created** is always listed and marked **new**, with every line it wrote
counted as added — even an empty file, which is still a change. Its card in the thread
shows what was written, as added lines, instead of saying nothing changed. A very large
new file shows only its beginning on the card, with a note that the rest was cut. In chats
from before this was fixed, a created file is still listed as new, but without a line
count, and its card asks you to open the file to see it.

### Where everything else went

The rail used to have four tabs and a stats strip. Issue #14 removed what was empty most of
Expand All @@ -130,7 +144,7 @@ the time or repeated what is shown elsewhere:
| --- | --- |
| **Research** tab (research runs started from this chat) | The research run's own page. Its Back button and breadcrumb lead to the chat it came from. |
| **Activity** tab (tool calls in this turn and earlier ones) | The run's own page, `/runs/<id>`, which has every tool call and event. Open it from **Run → Timeline** in a reply's stats popover, or by clicking the **running** chip at the top of the chat while a turn runs. |
| Stats strip: context and cost | The chat's header on every screen size: the context ring (hover for the breakdown and a Compact button) and the metered cost, when there is any. |
| Stats strip: context and cost | The top of the chat on every screen size: the context ring (hover for the breakdown and a Compact button) and the metered cost, when there is any. On a desktop both sit in the bar above the thread. On a tablet or phone the ring is in the chat's header, and the cost is beside it on a tablet and in the row of chips under the header on a phone. |
| Stats strip: tokens and latency | Per reply, in the reply's stats popover. |
| Files placeholder ("main · clean", Switch, Pull) | The real Files tab above. |

Expand Down
5 changes: 2 additions & 3 deletions docs/research/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,8 @@ The Research agent isn't only an initiator. Once a report or earlier findings ar

1. `/research/{id}` shows the run's status, sub-questions, sources and live trace. It refreshes every few seconds while the run is going and stops once it has ended.
2. The page has a **Cancel** button. Cancelling marks the run canceled and cancels its background job. The run stops at its next safe point and stays canceled.
3. In a chat linked to runs (for example the automation's conversation), the right-hand console's **Research** tab lists the chat's five most recent runs with their status and progress.
4. `/research` lists recent runs alongside generated images.
5. When a run fails, its error shows on its page and its job shows as failed in the review inbox. No notification is sent.
3. `/research` lists recent runs alongside generated images.
4. When a run fails, its error shows on its page and its job shows as failed in the review inbox. No notification is sent.

## Roles and permissions

Expand Down
2 changes: 1 addition & 1 deletion docs/structure/claude-desktop-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ This is the Cowork comparison, and it is the one I got wrong in the first draft:
| Connectors (Slack, M365, Salesforce…) | **absent** | — | write-capable connectors, Claude Tag for Slack |
| Computer use | **far behind** | `browser_screenshot` only | screen access, click, navigate (research preview) |
| Artifacts / side-panel documents | **n/a** | removed in #13 — files on disk and git instead | versioned, publishable, in-place draft editing |
| Preview a file or a website in a side panel | **even** (#29, #14) | right rail, folded to a strip until something is opened (remembered per user): Preview tab renders markdown, highlighted code, images, PDFs, directory listing, sandboxed URL iframe, selection persists per conversation; Files tab lists every file the agent changed in the chat with +/- counts and opens it in Preview. Run activity is on `/runs/<id>`, linked from each reply | artifact + document preview |
| Preview a file or a website in a side panel | **even** (#29, #14) | right rail, folded to a strip until something is opened (remembered per user): Preview tab renders markdown, highlighted code, images, PDFs, directory listing, sandboxed URL iframe, selection persists per conversation; Files tab lists every file the agent created or edited in the chat, with +/- counts and a "new" badge for created files, and opens it in Preview. Run activity is on `/runs/<id>`, linked from each reply | artifact + document preview |
| Inline charts and visualizations | **absent** | markdown only | interactive charts, diagrams, Claude Design |
| Conversation rename | **even** (#18) | Rename in each sidebar row's menu. Until #18 `updateConversationMeta` existed with no UI calling it, so this row's earlier "even" was wrong | same |
| Pin / archive / folders | **behind** (#18) | pin to a Pinned group on top; archive (the default way to tidy, one click on the row) hides a chat and keeps everything, with an Archived view to restore it; replying in an archived chat brings it back; delete is last in the menu, behind a confirmation. No folders | full |
Expand Down
76 changes: 68 additions & 8 deletions src/lib/chat-console/ChatConsoleRail.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { onMount, tick, untrack } from 'svelte';
import Icon from './Icon.svelte';
import PreviewPane from './PreviewPane.svelte';
import { consoleState } from './console-state.svelte';
Expand All @@ -12,7 +12,7 @@
previewState,
setRailTab,
} from './preview-state.svelte';
import { RAIL_TABS } from './preview-kinds';
import { RAIL_TABS, type RailTab } from './preview-kinds';

/*
* #14 — two tabs, folded away until there is something to show.
Expand Down Expand Up @@ -44,19 +44,71 @@
});

onMount(() => void hydrateRailOpen());

/*
* Folding and expanding swap the strip for the full rail, which removes the button that
* was just pressed and would drop keyboard focus to the page. After each of the rail's
* own controls, focus moves to the control that now stands where it was: expanding lands
* on the tab that was asked for (or the collapse button), folding lands on the expand
* button. The same goes for the Files list and the preview bar, which a click in them
* removes. Nothing moves when focus is still somewhere, such as on a button that stayed.
*/
let expandButton = $state<HTMLButtonElement | null>(null);
let collapseButton = $state<HTMLButtonElement | null>(null);
const tabButtons = $state<Partial<Record<RailTab, HTMLButtonElement | null>>>({});

async function refocus(target: () => HTMLElement | null | undefined) {
await tick();
const active = document.activeElement;
if (active && active !== document.body) return;
target()?.focus();
}

function expandFromStrip() {
expandRail();
void refocus(() => collapseButton);
}

function showTabFromStrip(tab: RailTab) {
setRailTab(tab);
void refocus(() => tabButtons[tab] ?? collapseButton);
}

function collapseFromTabs() {
collapseRail();
void refocus(() => expandButton);
}

/** Opening a file from Files switches to Preview, which removes the Files list. */
function openFromFiles(path: string) {
openFilePreview(path);
void refocus(() => tabButtons.Preview);
}

/** Closing the preview removes its bar, and on the column folds the rail too. */
function afterPreviewClosed() {
void refocus(() => (collapsed ? expandButton : tabButtons.Preview));
}
</script>

{#if collapsed}
<aside class="console-rail is-collapsed" aria-label="Chat rail">
<button type="button" class="console-rail__strip-btn" title="Expand rail" aria-label="Expand rail" onclick={expandRail}>
<button
type="button"
class="console-rail__strip-btn"
title="Expand rail"
aria-label="Expand rail"
bind:this={expandButton}
onclick={expandFromStrip}
>
<Icon name="panel" size={15} />
</button>
<button
type="button"
class="console-rail__strip-btn"
title={hasPreview ? 'Preview — something is open' : 'Preview'}
aria-label="Show Preview"
onclick={() => setRailTab('Preview')}
onclick={() => showTabFromStrip('Preview')}
>
<Icon name="search" size={15} />
{#if hasPreview}<span class="console-rail__strip-dot" aria-hidden="true"></span>{/if}
Expand All @@ -66,7 +118,7 @@
class="console-rail__strip-btn"
title={changedFiles.length > 0 ? `Files — ${changedFiles.length} changed in this chat` : 'Files'}
aria-label="Show Files"
onclick={() => setRailTab('Files')}
onclick={() => showTabFromStrip('Files')}
>
<Icon name="file" size={15} />
{#if changedFiles.length > 0}
Expand All @@ -84,6 +136,7 @@
role="tab"
aria-selected={activeTab === tab}
class="console-rail__tab {activeTab === tab ? 'active' : ''}"
bind:this={tabButtons[tab]}
onclick={() => setRailTab(tab)}
>
{tab}
Expand All @@ -94,15 +147,22 @@
{/each}
</div>
{#if variant === 'column'}
<button type="button" class="console-rail__collapse" title="Collapse rail" aria-label="Collapse rail" onclick={collapseRail}>
<button
type="button"
class="console-rail__collapse"
title="Collapse rail"
aria-label="Collapse rail"
bind:this={collapseButton}
onclick={collapseFromTabs}
>
<Icon name="panel" size={14} />
</button>
{/if}
</div>

<div class="console-rail__body {activeTab === 'Preview' ? 'is-preview' : ''}">
{#if activeTab === 'Preview'}
<PreviewPane {conversationId} />
<PreviewPane {conversationId} onClosed={afterPreviewClosed} />
{:else}
<div class="console-rail__sec">
<div class="lbl">
Expand All @@ -116,7 +176,7 @@
<ul class="console-files">
{#each changedFiles as file (file.path)}
<li>
<button type="button" class="console-files__row" title={`Preview ${file.path}`} onclick={() => openFilePreview(file.path)}>
<button type="button" class="console-files__row" title={`Preview ${file.path}`} onclick={() => openFromFiles(file.path)}>
<Icon name="file" size={12} />
<span class="console-files__path">
<span class="console-files__name">{file.name}</span>
Expand Down
19 changes: 17 additions & 2 deletions src/lib/chat-console/PreviewPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@
previewState,
} from './preview-state.svelte';

let { conversationId }: { conversationId: string | null } = $props();
let {
conversationId,
onClosed,
}: {
conversationId: string | null;
/** After "Close preview" — which removes that button, and may fold the rail (#14). */
onClosed?: () => void;
} = $props();

let address = $state('');
let addressError = $state<string | null>(null);
Expand Down Expand Up @@ -172,7 +179,15 @@
<Icon name="external" size={12} />
</a>
{/if}
<button type="button" title="Close preview" aria-label="Close preview" onclick={clearPreview}>
<button
type="button"
title="Close preview"
aria-label="Close preview"
onclick={() => {
clearPreview();
onClosed?.();
}}
>
<Icon name="x" size={12} />
</button>
</div>
Expand Down
35 changes: 35 additions & 0 deletions src/lib/chat-console/RailHeaderButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script lang="ts">
import { MediaQuery } from 'svelte/reactivity';
import { mobileDrawerState } from './mobile-drawer-state.svelte';
import { previewState, toggleRailFromHeader } from './preview-state.svelte';

/*
* #14 — the chat header's rail button, shown below the desktop breakpoint.
*
* It does two different things, and its name and `aria-expanded` follow whichever it does
* at the current width: on a phone it opens the rail's drawer; on a tablet, where the rail
* is a column beside the thread, it expands or folds that column. Same query as
* `isDrawerViewport` in preview-state, so the label never disagrees with the action.
*/
const drawerViewport = new MediaQuery('(max-width: 47.99rem)');

const expanded = $derived(drawerViewport.current ? mobileDrawerState.right : previewState.open);
const label = $derived(
drawerViewport.current ? 'Open chat rail' : previewState.open ? 'Collapse chat rail' : 'Expand chat rail'
);
</script>

<button
type="button"
onclick={toggleRailFromHeader}
class="console-iconbtn"
aria-label={label}
aria-expanded={expanded}
title={label}
style="width:32px;height:32px;border:1px solid var(--color-base-300);"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="15" y1="4" x2="15" y2="20" />
</svg>
</button>
6 changes: 4 additions & 2 deletions src/lib/chat-console/changed-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ function splitPath(path: string): { name: string; dir: string } {
* - Lists are read in order, oldest first: saved messages by sequence, then the live turn.
* - A block with an `id` is counted once even when two lists hold it.
* - Only successful `file_edit` blocks count; a write that changed nothing
* (`unavailable: 'no_change'`) is not a change.
* (`unavailable: 'no_change'`) is not a change. A created file always is: replies saved
* before `tool-result-details` learned to diff a new file stored every `Write` create as
* `no_change`, and those files belong on the list too.
* - Counts add up across edits, and a file created at any point stays `create`.
*/
export function collectChangedFiles(blockLists: Iterable<BlockList | null | undefined>): ChangedFile[] {
Expand All @@ -93,7 +95,7 @@ export function collectChangedFiles(blockLists: Iterable<BlockList | null | unde
}
if (failed(block) || !isFileEdit(block.details)) continue
const details = block.details
if (details.unavailable === 'no_change') continue
if (details.unavailable === 'no_change' && details.changeType !== 'create') continue

order += 1
const path = details.path.trim()
Expand Down
20 changes: 16 additions & 4 deletions src/lib/chat-console/preview-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ export async function hydratePreviewState(conversationId: string | null) {
let openLoaded = false;
/** Set once anything changes `open`; a stored value arriving later must not undo it. */
let openTouched = false;
/**
* What the database holds, as far as this page knows: null until it has been read or
* written. While it is unknown, `open` may be this browser's copy rather than the stored
* value, so a choice that matches it must still be saved.
*/
let savedOpen: boolean | null = null;
let openSaveTimer: ReturnType<typeof setTimeout> | null = null;

/** Load the viewer's remembered fold once per page load. Later chats reuse it from memory. */
Expand All @@ -172,6 +178,7 @@ export async function hydrateRailOpen() {
openLoaded = true;
try {
const stored = await getRailOpen();
savedOpen ??= stored; // a save that landed first knows better
if (!openTouched) {
previewState.open = stored;
writeRailOpenMirror(stored);
Expand All @@ -193,15 +200,20 @@ export async function hydrateRailOpen() {
function setOpen(open: boolean) {
if (isDrawerViewport()) return;
openTouched = true;
if (previewState.open === open) return;
if (previewState.open === open && (savedOpen === open || openSaveTimer)) return;
previewState.open = open;
writeRailOpenMirror(open);
if (openSaveTimer) clearTimeout(openSaveTimer);
openSaveTimer = setTimeout(() => {
openSaveTimer = null;
void setRailOpen(previewState.open).catch(() => {
// Same as the selection: the fold still holds for this session.
});
const next = previewState.open;
void setRailOpen(next)
.then(() => {
savedOpen = next;
})
.catch(() => {
// Same as the selection: the fold still holds for this session.
});
}, 400);
}

Expand Down
16 changes: 11 additions & 5 deletions src/lib/chat/FileEditCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,18 @@
details.changeType === 'create' ? 'Created' : details.tool === 'Write' ? 'Rewrote' : 'Edited'
);

// A created file's diff is its contents, so it has no lines only when it is empty — or
// when it was saved before new files were diffed, as "no change". It is never "no change".
const emptyReason = $derived(
details.unavailable === 'no_change'
? 'No changes — the file already matched what was written.'
: details.unavailable === 'diff_missing'
? 'The diff is unavailable for this edit (the previous contents were too large to diff).'
: ''
details.changeType === 'create'
? details.unavailable === 'none' && details.additions === 0
? 'An empty new file.'
: 'A new file — open it to see its contents.'
: details.unavailable === 'no_change'
? 'No changes — the file already matched what was written.'
: details.unavailable === 'diff_missing'
? 'The diff is unavailable for this edit (the previous contents were too large to diff).'
: ''
);

type NumberedLine = { text: string; cls: string; no: number | null };
Expand Down
Loading
Loading