diff --git a/packages/web/src/components/chat-room/chat-room-header.tsx b/packages/web/src/components/chat-room/chat-room-header.tsx index 5e78c22b..5b7504bf 100644 --- a/packages/web/src/components/chat-room/chat-room-header.tsx +++ b/packages/web/src/components/chat-room/chat-room-header.tsx @@ -2,15 +2,19 @@ import { Activity, + Check, FileText, Loader2, MessageCircle, PanelLeft, + Pencil, RotateCcw, + X, } from "lucide-react"; -import type { ReactElement } from "react"; +import { type FormEvent, type ReactElement, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Typography } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { buildChatRoomHeaderTabs } from "./chat-room-header-tabs"; @@ -23,17 +27,43 @@ export function ChatRoomHeader({ isRerunDisabled, isRerunning, isRerunVisible, + isRenamingTitle, title, onOpenAction, onOpenMessages, onOpenSidebar, onOpenTaskDetails, + onRenameTitle, onRerunWorkflow, }: ChatRoomHeaderProps): ReactElement { + const [draftTitle, setDraftTitle] = useState(title); + const [isEditingTitle, setIsEditingTitle] = useState(false); const tabs = buildChatRoomHeaderTabs({ activeTab, hasTaskDetails: Boolean(activeTaskId), }); + const trimmedDraftTitle = draftTitle.trim(); + + function startEditingTitle(): void { + setDraftTitle(title); + setIsEditingTitle(true); + } + + function cancelEditingTitle(): void { + setDraftTitle(title); + setIsEditingTitle(false); + } + + async function saveTitle(event: FormEvent): Promise { + event.preventDefault(); + if (!trimmedDraftTitle || isRenamingTitle) { + return; + } + const didSave = await onRenameTitle(trimmedDraftTitle); + if (didSave !== false) { + setIsEditingTitle(false); + } + } function selectTab(key: ChatRoomHeaderTabKey): void { if (key === "taskDetails") { @@ -61,9 +91,64 @@ export function ChatRoomHeader({ > -
- {title} -
+ {isEditingTitle ? ( +
void saveTitle(event)} + > + setDraftTitle(event.currentTarget.value)} + value={draftTitle} + /> + + +
+ ) : ( + <> +
+ + {title} + +
+ + + )}
{isRerunVisible ? ( diff --git a/packages/web/src/components/chat-room/chat-room-panel-view.tsx b/packages/web/src/components/chat-room/chat-room-panel-view.tsx index de0c3b02..20aff7e9 100644 --- a/packages/web/src/components/chat-room/chat-room-panel-view.tsx +++ b/packages/web/src/components/chat-room/chat-room-panel-view.tsx @@ -27,6 +27,7 @@ export function ChatRoomPanelView({ isRerunDisabled, isRerunning, isRerunVisible, + isRenamingTitle, isSending, isPlanning, isThinking, @@ -44,6 +45,7 @@ export function ChatRoomPanelView({ onOpenMessages, onOpenSidebar, onOpenTaskDetails, + onRenameTitle, onRerunWorkflow, onSelectCommand, onSelectOption, @@ -93,11 +95,13 @@ export function ChatRoomPanelView({ isRerunDisabled={isRerunDisabled} isRerunning={isRerunning} isRerunVisible={isRerunVisible} + isRenamingTitle={isRenamingTitle} title={selectedSession.title} onOpenAction={onOpenAction} onOpenMessages={onOpenMessages} onOpenSidebar={onOpenSidebar} onOpenTaskDetails={onOpenTaskDetails} + onRenameTitle={onRenameTitle} onRerunWorkflow={onRerunWorkflow} /> {layout.contentMode === "taskDetails" ? ( diff --git a/packages/web/src/components/chat-room/chat-room-panel.tsx b/packages/web/src/components/chat-room/chat-room-panel.tsx index a0f83d78..f7029733 100644 --- a/packages/web/src/components/chat-room/chat-room-panel.tsx +++ b/packages/web/src/components/chat-room/chat-room-panel.tsx @@ -17,18 +17,16 @@ import { useRealtimeStore } from "@/lib/realtime"; import { useChatClarificationState } from "./chat-clarification-state"; import { createChatClarificationSubmitters } from "./chat-clarification-submitters"; -import { - createStreamLine, - streamLineFromCommandEvent, -} from "./chat-command-stream-lines"; import { parseChatCommand } from "./chat-command-utils"; import { executeChatRoomInput } from "./chat-room-execute-input"; import { useChatRoomMission } from "./chat-room-mission"; import { useChatRoomDraftState } from "./chat-room-panel-draft-state"; import { ChatRoomPanelView } from "./chat-room-panel-view"; +import { rerunChatSessionWorkflow } from "./chat-room-rerun-workflow"; import { selectChatSession } from "./chat-room-selection"; import { resolveChatRoomStreamState } from "./chat-room-stream-state"; import { resolveChatSessionRerunState } from "./chat-session-rerun-state"; +import { createChatSessionTitleRenameHandler } from "./chat-session-title-rename"; import { useWorkingSectionState } from "./chat-working-section-state"; import type * as CRT from "./types/chat-room.types"; import { useChatRoomContentModeState } from "./use-chat-room-content-mode-state"; @@ -115,6 +113,11 @@ export function ChatRoomPanel({ selectedSession, sendMessage: (input) => sendMessage.mutateAsync(input), }); + const renameSessionTitle = createChatSessionTitleRenameHandler({ + renameSession: (input) => updateSession.mutateAsync(input), + selectedSession, + showError: (message) => toast.error(message), + }); async function startNewSession(): Promise { if (!workspaceId) { @@ -173,39 +176,18 @@ export function ChatRoomPanel({ toast.error("Workflow cannot be rerun yet."); return; } - let finalStatus: "succeeded" | "failed" | "rejected" | null = null; - let finalError: string | undefined; - setIsRerunning(true); - setSubmittedRerunKey(rerunSubmissionKey); - setCommandLines([createStreamLine("system", "Queued workflow rerun.")]); - try { - await apiClient.streamCliCommand(rerunState.command, (event) => { - if (event.type === "complete") { - finalStatus = event.result.status; - finalError = event.result.error; - } - const line = streamLineFromCommandEvent(event); - if (line) setCommandLines((current) => [...current, line]); - }); - await Promise.allSettled([ - sessionsQuery.refetch(), - messagesQuery.refetch(), - refetchActiveTask(), - ]); - if (finalStatus && finalStatus !== "succeeded") { - toast.error(finalError ?? "Workflow rerun failed."); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Workflow rerun failed."; - setCommandLines((current) => [ - ...current, - createStreamLine("stderr", message), - ]); - toast.error(message); - } finally { - setIsRerunning(false); - } + await rerunChatSessionWorkflow({ + command: rerunState.command, + refetchActiveTask, + refetchMessages: messagesQuery.refetch, + refetchSessions: sessionsQuery.refetch, + setCommandLines, + setIsRerunning, + setSubmittedRerunKey, + streamCliCommand: apiClient.streamCliCommand, + submissionKey: rerunSubmissionKey, + showError: (message) => toast.error(message), + }); } return ( @@ -218,6 +200,7 @@ export function ChatRoomPanel({ isRerunDisabled={rerunState.isDisabled} isRerunning={isRerunning} isRerunVisible={rerunState.isVisible} + isRenamingTitle={updateSession.isPending} isSending={sendMessage.isPending} isPlanning={isPlanning} isThinking={isThinking} @@ -237,6 +220,7 @@ export function ChatRoomPanel({ onOpenMessages={contentMode.openMessages} onOpenSidebar={onOpenSidebar} onOpenTaskDetails={contentMode.openTaskDetails} + onRenameTitle={renameSessionTitle} onRerunWorkflow={() => void handleRerunWorkflow()} onSelectCommand={setDraft} onSelectOption={(index, value) => diff --git a/packages/web/src/components/chat-room/chat-room-rerun-workflow.ts b/packages/web/src/components/chat-room/chat-room-rerun-workflow.ts new file mode 100644 index 00000000..675fbc8e --- /dev/null +++ b/packages/web/src/components/chat-room/chat-room-rerun-workflow.ts @@ -0,0 +1,68 @@ +import type { CliCommandStreamEvent, CliCommandStreamRequest } from "@/lib/api"; +import type { Dispatch, SetStateAction } from "react"; +import { + createStreamLine, + streamLineFromCommandEvent, +} from "./chat-command-stream-lines"; +import type { ChatStreamLine } from "./types/chat-room.types"; + +export async function rerunChatSessionWorkflow({ + command, + refetchActiveTask, + refetchMessages, + refetchSessions, + setCommandLines, + setIsRerunning, + setSubmittedRerunKey, + streamCliCommand, + submissionKey, + showError, +}: { + command: CliCommandStreamRequest; + refetchActiveTask: () => Promise; + refetchMessages: () => Promise; + refetchSessions: () => Promise; + setCommandLines: Dispatch>; + setIsRerunning: (value: boolean) => void; + setSubmittedRerunKey: (value: string) => void; + streamCliCommand: ( + command: CliCommandStreamRequest, + onEvent: (event: CliCommandStreamEvent) => void, + ) => Promise; + submissionKey: string; + showError: (message: string) => void; +}): Promise { + let finalStatus: "succeeded" | "failed" | "rejected" | null = null; + let finalError: string | undefined; + setIsRerunning(true); + setSubmittedRerunKey(submissionKey); + setCommandLines([createStreamLine("system", "Queued workflow rerun.")]); + try { + await streamCliCommand(command, (event) => { + if (event.type === "complete") { + finalStatus = event.result.status; + finalError = event.result.error; + } + const line = streamLineFromCommandEvent(event); + if (line) setCommandLines((current) => [...current, line]); + }); + await Promise.allSettled([ + refetchSessions(), + refetchMessages(), + refetchActiveTask(), + ]); + if (finalStatus && finalStatus !== "succeeded") { + showError(finalError ?? "Workflow rerun failed."); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Workflow rerun failed."; + setCommandLines((current) => [ + ...current, + createStreamLine("stderr", message), + ]); + showError(message); + } finally { + setIsRerunning(false); + } +} diff --git a/packages/web/src/components/chat-room/chat-session-title-rename.ts b/packages/web/src/components/chat-room/chat-session-title-rename.ts new file mode 100644 index 00000000..c38f6331 --- /dev/null +++ b/packages/web/src/components/chat-room/chat-session-title-rename.ts @@ -0,0 +1,34 @@ +import type { ChatSessionRecord, ChatSessionUpdateRequest } from "@/lib/api"; + +export function createChatSessionTitleRenameHandler({ + renameSession, + selectedSession, + showError, +}: { + renameSession: (input: { + sessionId: string; + session: ChatSessionUpdateRequest; + }) => Promise; + selectedSession: ChatSessionRecord | null; + showError: (message: string) => void; +}): (title: string) => Promise { + return async (title) => { + const nextTitle = title.trim(); + if (!selectedSession || !nextTitle) { + return false; + } + if (nextTitle === selectedSession.title.trim()) { + return true; + } + try { + await renameSession({ + sessionId: selectedSession.id, + session: { title: nextTitle }, + }); + return true; + } catch (error) { + showError(error instanceof Error ? error.message : "Rename failed"); + return false; + } + }; +} diff --git a/packages/web/src/components/chat-room/types/chat-room.types.ts b/packages/web/src/components/chat-room/types/chat-room.types.ts index 25f0ee04..dfbf6ce8 100644 --- a/packages/web/src/components/chat-room/types/chat-room.types.ts +++ b/packages/web/src/components/chat-room/types/chat-room.types.ts @@ -23,11 +23,13 @@ export interface ChatRoomHeaderProps { isRerunDisabled: boolean; isRerunning: boolean; isRerunVisible: boolean; + isRenamingTitle: boolean; title: string; onOpenAction: () => void; onOpenMessages: () => void; onOpenSidebar: () => void; onOpenTaskDetails: () => void; + onRenameTitle: (title: string) => Promise | boolean; onRerunWorkflow: () => void; } @@ -86,6 +88,7 @@ export interface ChatRoomPanelViewProps { isRerunDisabled: boolean; isRerunning: boolean; isRerunVisible: boolean; + isRenamingTitle: boolean; isSending: boolean; isPlanning: boolean; isThinking: boolean; @@ -103,6 +106,7 @@ export interface ChatRoomPanelViewProps { onOpenMessages: () => void; onOpenSidebar: () => void; onOpenTaskDetails: () => void; + onRenameTitle: (title: string) => Promise | boolean; onRerunWorkflow: () => void; onSelectCommand: (value: string) => void; onSelectOption: (index: number, value: string) => Promise | void; diff --git a/packages/web/tests/chat-client.test.ts b/packages/web/tests/chat-client.test.ts index d18c61d4..07134bbf 100644 --- a/packages/web/tests/chat-client.test.ts +++ b/packages/web/tests/chat-client.test.ts @@ -76,6 +76,34 @@ describe("chat API client", () => { expect(session.archived).toBe(true); }); + it("sends title updates for chat sessions", async () => { + const fetchFn = (async (input: URL | RequestInfo, init?: RequestInit) => { + expect(String(input)).toBe("/api/chat/sessions/session-1"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ title: "Renamed" }); + return okJsonResponse({ + id: "session-1", + workspaceId: "owner-1", + projectId: "default", + taskId: "task-1", + title: "Renamed", + pendingRequest: null, + pendingQuestions: [], + archived: false, + createdAt: "2026-05-20T00:00:00.000Z", + updatedAt: "2026-05-20T00:01:00.000Z", + lastSeenAt: "2026-05-20T00:00:00.000Z", + }); + }) as typeof fetch; + const client = createApiClient({ fetchFn }); + + const session = await client.updateChatSession("session-1", { + title: "Renamed", + }); + + expect(session.title).toBe("Renamed"); + }); + it("parses chat send responses with the linked issue", async () => { const fetchFn = (async (input: URL | RequestInfo, init?: RequestInit) => { expect(String(input)).toBe("/api/chat/sessions/session-1/send"); diff --git a/packages/web/tests/chat-session-cache.test.ts b/packages/web/tests/chat-session-cache.test.ts index 6d97c498..d568e3c4 100644 --- a/packages/web/tests/chat-session-cache.test.ts +++ b/packages/web/tests/chat-session-cache.test.ts @@ -40,6 +40,19 @@ describe("chat session cache", () => { expect(preserved).toEqual([seenAtT2]); expect(isChatSessionUnread(preserved[0] ?? delayedUnreadAtT2)).toBe(false); }); + + it("replaces cached sessions with newer renamed titles", () => { + const current = chatSession({ + title: "Untitled", + updatedAt: "2026-05-16T00:01:00.000Z", + }); + const renamed = chatSession({ + title: "Renamed", + updatedAt: "2026-05-16T00:02:00.000Z", + }); + + expect(mergeChatSessions([current], [renamed])).toEqual([renamed]); + }); }); function chatSession( diff --git a/packages/web/tests/chat-session-title-rename.test.ts b/packages/web/tests/chat-session-title-rename.test.ts new file mode 100644 index 00000000..c449b458 --- /dev/null +++ b/packages/web/tests/chat-session-title-rename.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { createChatSessionTitleRenameHandler } from "../src/components/chat-room/chat-session-title-rename"; +import type { ChatSessionRecord } from "../src/lib/api"; + +describe("chat session title rename", () => { + it("trims changed titles before updating the session", async () => { + const updates: unknown[] = []; + const renameTitle = createChatSessionTitleRenameHandler({ + renameSession: async (input) => { + updates.push(input); + }, + selectedSession: chatSession({ title: "Untitled" }), + showError: () => {}, + }); + + await expect(renameTitle(" Renamed ")).resolves.toBe(true); + + expect(updates).toEqual([ + { + sessionId: "session-1", + session: { title: "Renamed" }, + }, + ]); + }); + + it("skips empty and unchanged titles", async () => { + const updates: unknown[] = []; + const renameTitle = createChatSessionTitleRenameHandler({ + renameSession: async (input) => { + updates.push(input); + }, + selectedSession: chatSession({ title: "Untitled" }), + showError: () => {}, + }); + + await expect(renameTitle(" ")).resolves.toBe(false); + await expect(renameTitle(" Untitled ")).resolves.toBe(true); + + expect(updates).toEqual([]); + }); + + it("keeps edit mode open when the update fails", async () => { + const errors: string[] = []; + const renameTitle = createChatSessionTitleRenameHandler({ + renameSession: async () => { + throw new Error("Network failed"); + }, + selectedSession: chatSession({ title: "Untitled" }), + showError: (message) => errors.push(message), + }); + + await expect(renameTitle("Renamed")).resolves.toBe(false); + + expect(errors).toEqual(["Network failed"]); + }); +}); + +function chatSession( + overrides: Partial = {}, +): ChatSessionRecord { + return { + id: "session-1", + workspaceId: "owner-1", + projectId: "default", + taskId: "task-1", + title: "Untitled", + pendingRequest: null, + pendingQuestions: [], + archived: false, + workflowState: null, + createdAt: "2026-05-16T00:00:00.000Z", + updatedAt: "2026-05-16T00:00:00.000Z", + lastSeenAt: "2026-05-16T00:00:00.000Z", + ...overrides, + }; +}