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
2 changes: 2 additions & 0 deletions dashboard/src/v2/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export const ChatPage: FunctionComponent = () => {
handleSend,
navigateHistory,
handleDeleteThread,
handleRenameThread,
createThreadForCompose,
threadIndex,
invocationIndex,
Expand Down Expand Up @@ -366,6 +367,7 @@ export const ChatPage: FunctionComponent = () => {
thread={selectedThread}
onCompact={() => void handleCompactThread()}
onCancelActiveTurn={() => void handleCancelActiveTurn()}
onRename={handleRenameThread}
isCompacting={compacting}
isCancelling={isCancelling}
/>
Expand Down
148 changes: 143 additions & 5 deletions dashboard/src/v2/components/chat/ChatThreadHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { h, type FunctionComponent } from "preact";
import { AlertCircle, Zap, Activity, XCircle } from "lucide-preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { AlertCircle, Zap, Activity, XCircle, PencilLine, Check, X, RefreshCw } from "lucide-preact";
import type { ChatThread } from "../../types.js";
import { useInteractionTokens } from "../../lib/motion/tokens.js";

Expand Down Expand Up @@ -32,6 +33,7 @@ interface ChatThreadHeaderProps {
thread: ChatThread | null;
onCompact: () => void;
onCancelActiveTurn: () => void;
onRename: (title: string) => Promise<unknown>;
isCompacting: boolean;
isCancelling: boolean;
}
Expand All @@ -40,20 +42,76 @@ export const ChatThreadHeader: FunctionComponent<ChatThreadHeaderProps> = ({
thread,
onCompact,
onCancelActiveTurn,
onRename,
isCompacting,
isCancelling,
}) => {
const assignedLabel = resolveAssignedLabel(thread);
const interactionTokens = useInteractionTokens();
const inputRef = useRef<HTMLInputElement>(null);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(thread?.title || "");
const [renamePending, setRenamePending] = useState(false);
const [renameError, setRenameError] = useState<string | null>(null);

const isReplayRequired = thread?.runtimeState?.replayRequired;
const hasActiveSession = thread?.runtimeState?.sessionIds && thread.runtimeState.sessionIds.length > 0;
const isNewOrCompacted = !hasActiveSession || isReplayRequired;
const titleErrorId = thread ? `thread-title-error-${thread.id}` : undefined;

useEffect(() => {
if (!isEditingTitle) {
setTitleDraft(thread?.title || "");
setRenameError(null);
}
}, [isEditingTitle, thread?.id, thread?.title]);

useEffect(() => {
if (isEditingTitle) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditingTitle]);

const cancelRename = (): void => {
setTitleDraft(thread?.title || "");
setRenameError(null);
setIsEditingTitle(false);
};

const saveRename = async (): Promise<void> => {
if (!thread || renamePending) {
return;
}

const trimmedTitle = titleDraft.trim();
if (!trimmedTitle) {
setRenameError("Thread title is required.");
return;
}

if (trimmedTitle === thread.title) {
setIsEditingTitle(false);
setRenameError(null);
return;
}

setRenamePending(true);
setRenameError(null);
try {
await onRename(trimmedTitle);
setIsEditingTitle(false);
} catch (error) {
setRenameError(error instanceof Error ? error.message : String(error));
} finally {
setRenamePending(false);
}
};

return (
<div className="shrink-0 border-b border-black/[0.05] px-6 py-5 dark:border-white/[0.05]">
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4 sm:gap-6">
<div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-signal-500">Active Thread</div>
{isReplayRequired && (
Expand All @@ -74,9 +132,89 @@ export const ChatThreadHeader: FunctionComponent<ChatThreadHeaderProps> = ({
</span>
)}
</div>
<h2 className="mt-2 font-display text-2xl font-semibold tracking-tight text-slate-900 dark:text-white break-words min-w-0 w-full">
{thread?.title || "No Thread Selected"}
</h2>
{isEditingTitle && thread ? (
<div className="mt-2 min-w-0">
<label htmlFor="thread-title-input" className="sr-only">Thread title</label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
id="thread-title-input"
ref={inputRef}
value={titleDraft}
disabled={renamePending}
aria-invalid={renameError ? "true" : "false"}
aria-describedby={renameError && titleErrorId ? titleErrorId : undefined}
onInput={(event) => {
setTitleDraft(event.currentTarget.value);
if (renameError) {
setRenameError(null);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelRename();
return;
}
if (event.key === "Enter") {
event.preventDefault();
void saveRename();
}
}}
className="min-h-11 min-w-0 flex-1 rounded-2xl border border-black/[0.08] bg-white/75 px-3 py-2 font-display text-xl font-semibold tracking-tight text-slate-900 outline-none transition focus:border-signal-500 focus:ring-2 focus:ring-signal-500/25 disabled:cursor-wait disabled:opacity-70 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white"
/>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void saveRename()}
disabled={renamePending}
aria-busy={renamePending}
aria-label={renamePending ? "Saving thread title" : "Save thread title"}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-signal-500/25 bg-signal-500/15 text-signal-700 transition hover:border-signal-500/40 hover:bg-signal-500/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-70 dark:text-signal-400 dark:focus-visible:ring-offset-void-900"
title="Save thread title"
>
{renamePending ? <RefreshCw className="h-4 w-4 animate-spin motion-reduce:animate-none" /> : <Check className="h-4 w-4" />}
</button>
<button
type="button"
onClick={cancelRename}
disabled={renamePending}
aria-label="Cancel thread title edit"
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-black/[0.08] bg-white/70 text-slate-500 transition hover:bg-black/[0.03] hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-70 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-slate-300 dark:hover:bg-white/[0.06] dark:hover:text-white dark:focus-visible:ring-offset-void-900"
title="Cancel thread title edit"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{renameError && titleErrorId && (
<div id={titleErrorId} role="alert" className="mt-2 flex items-center gap-2 text-xs font-medium text-status-red">
<AlertCircle className="h-3.5 w-3.5" />
{renameError}
</div>
)}
</div>
) : (
<div className="mt-2 flex min-w-0 items-start gap-2">
<h2 className="min-w-0 flex-1 break-words font-display text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
{thread?.title || "No Thread Selected"}
</h2>
{thread && (
<button
type="button"
onClick={() => {
setTitleDraft(thread.title);
setRenameError(null);
setIsEditingTitle(true);
}}
aria-label={`Rename ${thread.title}`}
className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-black/[0.08] bg-white/70 text-slate-500 transition hover:bg-black/[0.03] hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500 focus-visible:ring-offset-2 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-slate-300 dark:hover:bg-white/[0.06] dark:hover:text-white dark:focus-visible:ring-offset-void-900"
title="Rename thread"
>
<PencilLine className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
<div className="text-left sm:text-right text-[10px] font-mono text-slate-400 w-full sm:w-auto min-w-0">
<div className="mb-2 w-full">
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/v2/components/chat/ThreadListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export const ThreadListCard: FunctionComponent<{
</div>

{/* Title */}
<h3 className="font-display text-base font-semibold tracking-tight text-slate-900 dark:text-white leading-snug truncate">
<h3 className="line-clamp-2 min-h-[2.75rem] overflow-hidden break-words font-display text-base font-semibold leading-snug tracking-tight text-slate-900 dark:text-white">
{thread.title}
</h3>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/preact';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/preact';
import userEvent from "@testing-library/user-event";
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);

Expand Down Expand Up @@ -59,6 +60,7 @@ const mocks = vi.hoisted(() => {
handleSend: vi.fn(),
navigateHistory: vi.fn(() => false),
handleDeleteThread: vi.fn(),
handleRenameThread: vi.fn(() => Promise.resolve()),
createThreadForCompose: vi.fn(),
threadIndex: new Map(),
invocationIndex: new Map(),
Expand Down Expand Up @@ -109,6 +111,10 @@ vi.mock('../../../hooks/use-project-effective-settings.js', () => ({
}));

describe('ChatPage Accessibility', () => {
afterEach(() => {
cleanup();
});

beforeEach(() => {
vi.clearAllMocks();
mocks.reducedMotion.value = false;
Expand All @@ -125,6 +131,7 @@ describe('ChatPage Accessibility', () => {
input: "Ship it",
error: "Test error",
feedback: { status: "idle", message: null },
handleRenameThread: vi.fn(() => Promise.resolve()),
};
});

Expand Down Expand Up @@ -220,9 +227,9 @@ describe('ChatPage Accessibility', () => {
const detailPanel = screen.getByText('Transcript').closest('section');
const splitPane = detailPanel?.parentElement;

expect(rail).toHaveClass('h-full', 'overflow-hidden', 'lg:max-h-full');
expect(rail).toHaveClass('overflow-hidden', 'md:h-full', 'md:max-h-none');
expect(detailPanel).toHaveClass('min-h-0', 'overflow-hidden');
expect(splitPane).toHaveClass('min-h-0', 'overflow-hidden', 'lg:grid-rows-[minmax(0,1fr)]');
expect(splitPane).toHaveClass('min-h-0', 'overflow-hidden', 'md:grid-rows-[minmax(0,1fr)]');
});

it('has accessible message composer and regions', () => {
Expand Down Expand Up @@ -255,6 +262,49 @@ describe('ChatPage Accessibility', () => {
expect(liveError).toHaveAttribute('aria-live', 'polite');
});

it('keeps the active header and thread rail synchronized after rename', async () => {
const user = userEvent.setup();
const initialThread = { ...mocks.data.threads[0], title: "Thread 1" };
mocks.data = {
...mocks.data,
threads: [initialThread],
selectedThread: initialThread,
selectedThreadId: initialThread.id,
threadIndex: new Map([[initialThread.id, initialThread]]),
sending: false,
input: "",
error: null,
};
mocks.data.handleRenameThread = vi.fn(async (title: string) => {
const updatedThread = { ...initialThread, title, updatedAt: "2026-03-10T12:01:00.000Z" };
mocks.data.threads = [updatedThread];
mocks.data.selectedThread = updatedThread;
mocks.data.threadIndex = new Map([[updatedThread.id, updatedThread]]);
});

const renderPage = () => (
<ProjectDataContext.Provider value={{ projects: [{ id: "p1", name: "P" } as any], selectedProject: { id: "p1", name: "P" } as any } as any}>
<ChatPage />
</ProjectDataContext.Provider>
);
const { rerender } = render(renderPage());

await user.click(screen.getByRole("button", { name: "Rename Thread 1" }));
const titleInput = screen.getByRole("textbox", { name: "Thread title" });
await user.clear(titleInput);
await user.type(titleInput, "Renamed Session");
await user.click(screen.getByRole("button", { name: "Save thread title" }));

await waitFor(() => {
expect(mocks.data.handleRenameThread).toHaveBeenCalledWith("Renamed Session");
});

rerender(renderPage());

expect(screen.getAllByRole("heading", { name: "Renamed Session" })).toHaveLength(2);
expect(screen.getAllByText("Renamed Session")).toHaveLength(2);
});

it('suppresses duplicate invocation restarts and shows retry feedback', async () => {
mocks.data = {
...mocks.data,
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/v2/hooks/use-chat-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export const useChatPageData = (options?: { composerRef?: RefObject<HTMLTextArea
handleSend: threadData.handleSend,
navigateHistory: threadData.navigateHistory,
handleDeleteThread: threadData.handleDeleteThread,
handleRenameThread: threadData.handleRenameThread,
createThreadForCompose: threadData.createThreadForCompose,
threadIndex: threadData.threadIndex,
invocationIndex: invocationData.invocationIndex,
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/v2/hooks/use-chat-thread-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const areThreadsEqual = (left: ChatThread[], right: ChatThread[]): boolea
const candidate = right[index];
return Boolean(candidate)
&& candidate.id === thread.id
&& candidate.title === thread.title
&& candidate.updatedAt === thread.updatedAt
&& candidate.lastMessageAt === thread.lastMessageAt
&& candidate.lastMessagePreview === thread.lastMessagePreview
Expand Down Expand Up @@ -369,6 +370,22 @@ export const useChatThreadData = (options: {
}
}, [cache, refreshMessages, selectedProject, selectedThread, setSuccess, setThreadsSnapshot]);

const handleRenameThread = useCallback(async (title: string): Promise<ChatThread> => {
if (!selectedThread || !selectedProject) {
throw new Error("Select a thread before renaming it.");
}

const updated = await updateConversationThread(selectedThread.id, { title });
const nextThreads = (cache.getThreads(selectedProject.id) || threadsRef.current).map((thread) => (
thread.id === updated.id ? updated : thread
));
cache.setThreads(selectedProject.id, nextThreads);
setThreadsSnapshot(nextThreads);
setError(null);
setSuccess("Thread renamed.");
return updated;
}, [cache, selectedProject, selectedThread, setSuccess, setThreadsSnapshot]);

const recordSentMessage = useCallback((message: string): void => {
sentHistoryRef.current = [...sentHistoryRef.current.filter((entry) => entry !== message), message].slice(-50);
historyIndexRef.current = -1;
Expand Down Expand Up @@ -509,6 +526,7 @@ export const useChatThreadData = (options: {
handleSend,
navigateHistory,
handleDeleteThread,
handleRenameThread,
feedback,
clearFeedback,
isConfirmOpen,
Expand Down
Loading