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
36 changes: 5 additions & 31 deletions dashboard/src/v2/components/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ export const TopNav: FunctionComponent<TopNavProps> = ({ onMenuToggle, isMobile,
}, [dropdownOpen, filteredProjects.length, loading, projectFilter]);

useEffect(() => {
if (sprintDropdownOpen && !sprintsLoading && filteredSprints.length === 0 && !sprintFilter.toLowerCase().includes("all")) {
if (sprintDropdownOpen && !sprintsLoading && filteredSprints.length === 0) {
setNavAnnouncement(sprintFilter ? `No sprints match ${sprintFilter}` : "No sprints available");
}
}, [filteredSprints.length, sprintDropdownOpen, sprintFilter, sprintsLoading]);
Expand Down Expand Up @@ -435,7 +435,7 @@ export const TopNav: FunctionComponent<TopNavProps> = ({ onMenuToggle, isMobile,
/>
<span id="project-filter-desc" className="sr-only">Use arrow keys to navigate options.</span>
</div>
<div className="max-h-[calc(100dvh-5rem)] overflow-y-auto dropdown-scrollbar">
<div className="max-h-64 sm:max-h-72 md:max-h-80 overflow-y-auto dropdown-scrollbar">
{filteredProjects.length === 0 && (
<div className="px-3 py-4 text-center text-sm text-slate-500 dark:text-slate-400">
No projects found.
Expand Down Expand Up @@ -514,7 +514,7 @@ export const TopNav: FunctionComponent<TopNavProps> = ({ onMenuToggle, isMobile,
id="sprint-selector-button"
aria-label={`Sprint selector, selected sprint: ${sprintsLoading ? "Loading..." : selectedSprint ? formatSprintDisplay(selectedSprint, sprintKeyPrefix) : "All Sprints"}`}
aria-controls={sprintDropdownOpen && sprints.length > 0 ? "sprint-listbox" : undefined}
aria-activedescendant={sprintDropdownOpen && sprints.length > 0 ? (sprintKb.activeDescendantId || `sprint-option-${selectedSprintId || 'none'}`) : undefined}
aria-activedescendant={sprintDropdownOpen && sprints.length > 0 ? (sprintKb.activeDescendantId || (selectedSprintId ? `sprint-option-${selectedSprintId}` : undefined)) : undefined}
aria-busy={sprintSwitchBusy || sprintsLoading ? "true" : "false"}
onClick={(e) => {
if (sprints.length === 0) {
Expand Down Expand Up @@ -563,38 +563,12 @@ export const TopNav: FunctionComponent<TopNavProps> = ({ onMenuToggle, isMobile,
/>
<span id="sprint-filter-desc" className="sr-only">Use arrow keys to navigate options.</span>
</div>
<div className="max-h-[calc(100dvh-5rem)] overflow-y-auto dropdown-scrollbar">
{filteredSprints.length === 0 && !sprintFilter.toLowerCase().includes('all') && (
<div className="max-h-64 sm:max-h-72 md:max-h-80 overflow-y-auto dropdown-scrollbar">
{filteredSprints.length === 0 && (
<div className="px-3 py-4 text-center text-sm text-slate-500 dark:text-slate-400">
No sprints found.
</div>
)}
{(sprintFilter === '' || 'all sprints'.includes(sprintFilter.toLowerCase())) && (
<button
id="sprint-option-none"
role="option"
aria-selected={selectedSprintId === null}
onClick={async () => {
setSprintSwitchBusy(true);
setNavAnnouncement('Switching sprint to All Sprints...');
try {
await selectSprint(null);
setNavAnnouncement('Sprint switched to All Sprints');
setSprintDropdownOpen(false);
} finally {
setSprintSwitchBusy(false);
}
}}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/50 w-full flex items-center gap-2.5 px-3 py-3 min-h-[44px] text-left hover:bg-signal-500/5 transition-colors group ${selectedSprintId === null ? 'bg-signal-500/8' : ''}`}
>
<span className={`text-sm font-medium font-mono truncate transition-colors ${selectedSprintId === null ? 'text-signal-600 dark:text-signal-400 font-semibold' : 'text-slate-700 dark:text-slate-300'}`}>
All Sprints
</span>
{selectedSprintId === null && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-signal-500" />
)}
</button>
)}
{filteredSprints.map((sprint) => (
<button
key={sprint.id}
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
Loading