From aef9996edfe89d7f0af5119d8ffb42172bc24324 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:21:16 +0000 Subject: [PATCH 1/6] feat(task T04): implement via codex --- .../sprints/SprintJiraImportModal.tsx | 121 ++++++++++++++---- docs/dashboard/sprint-imports.md | 6 +- .../sprints/SprintJiraImportModal.test.tsx | 77 +++++++++++ 3 files changed, 178 insertions(+), 26 deletions(-) diff --git a/dashboard/src/v2/components/sprints/SprintJiraImportModal.tsx b/dashboard/src/v2/components/sprints/SprintJiraImportModal.tsx index 69300ad5db..d2c39ba395 100644 --- a/dashboard/src/v2/components/sprints/SprintJiraImportModal.tsx +++ b/dashboard/src/v2/components/sprints/SprintJiraImportModal.tsx @@ -98,7 +98,9 @@ export const SprintJiraImportModal = ({ const [sortDirection, setSortDirection] = useState("desc"); const [limit, setLimit] = useState(40); const [jql, setJql] = useState(""); + const [hideInWork, setHideInWork] = useState(true); const [advancedFiltersExpanded, setAdvancedFiltersExpanded] = useState(false); + const [fetchedResults, setFetchedResults] = useState([]); const [results, setResults] = useState([]); const [hasSearched, setHasSearched] = useState(false); const [selectedKeys, setSelectedKeys] = useState>(new Set()); @@ -108,6 +110,7 @@ export const SprintJiraImportModal = ({ const [importing, setImporting] = useState(false); const [error, setError] = useState(null); const abortRef = useRef(null); + const hideInWorkRef = useRef(true); const selectedIssues = useMemo(() => ( results.filter((issue) => selectedKeys.has(issue.key)) @@ -144,6 +147,14 @@ export const SprintJiraImportModal = ({ const emptyStateCopy = getIssueImportEmptyStateCopy("jira", hasSearched); const compactState = buildIssueImportCompactState({ filters: [ + { + id: "hideInWork", + label: "Visibility", + value: hideInWork, + defaultValue: false, + valueLabel: hideInWork ? "Hide in Work" : null, + priority: 1, + }, { id: "status", label: "Status", @@ -154,17 +165,17 @@ export const SprintJiraImportModal = ({ alwaysShow: true, priority: 0, }, - { id: "project", label: "Project", value: projectKey, priority: 1 }, - { id: "issue", label: "Issue", value: issueKey, priority: 2 }, - { id: "search", label: "Text", value: search, priority: 3 }, - { id: "assignee", label: "Assignee", value: assigneeText, priority: 4 }, - { id: "reporter", label: "Reporter", value: reporterText, priority: 5 }, - { id: "type", label: "Type", value: issueType, priority: 6 }, - { id: "priority", label: "Priority", value: priority, priority: 7 }, - { id: "labels", label: "Labels", value: labels, priority: 8 }, - { id: "updatedAfter", label: "Updated after", value: updatedAfter, priority: 9 }, - { id: "updatedBefore", label: "Updated before", value: updatedBefore, priority: 10 }, - { id: "jql", label: "JQL", value: jql, priority: 11 }, + { id: "project", label: "Project", value: projectKey, priority: 2 }, + { id: "issue", label: "Issue", value: issueKey, priority: 3 }, + { id: "search", label: "Text", value: search, priority: 4 }, + { id: "assignee", label: "Assignee", value: assigneeText, priority: 5 }, + { id: "reporter", label: "Reporter", value: reporterText, priority: 6 }, + { id: "type", label: "Type", value: issueType, priority: 7 }, + { id: "priority", label: "Priority", value: priority, priority: 8 }, + { id: "labels", label: "Labels", value: labels, priority: 9 }, + { id: "updatedAfter", label: "Updated after", value: updatedAfter, priority: 10 }, + { id: "updatedBefore", label: "Updated before", value: updatedBefore, priority: 11 }, + { id: "jql", label: "JQL", value: jql, priority: 12 }, ], selectedCount: selectedIssues.length, visibleCount: results.length, @@ -225,25 +236,17 @@ export const SprintJiraImportModal = ({ }, controller.signal, ); - setResults(data); - setSelectedKeys((current) => new Set([...current].filter((key) => data.some((issue) => issue.key === key)))); - setConversationDisabledKeys((current) => new Set([...current].filter((key) => data.some((issue) => issue.key === key)))); - setImportModes((current) => { - const visibleKeys = new Set(data.map((issue) => issue.key)); - const next: Record = {}; - for (const [key, mode] of Object.entries(current)) { - if (visibleKeys.has(key)) { - next[key] = mode; - } - } - return next; - }); + const visibleData = filterVisibleJiraIssues(data, hideInWorkRef.current); + setFetchedResults(data); + setResults(visibleData); + pruneIssueStateToVisibleResults(visibleData); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { return; } const copy = getIssueImportErrorCopy(err, "Jira search failed. Check the filters and try again."); setError(`Jira search error: ${copy.message}`); + setFetchedResults([]); setResults([]); } finally { if (abortRef.current === controller) { @@ -350,6 +353,29 @@ export const SprintJiraImportModal = ({ setImportModes({}); }; + const pruneIssueStateToVisibleResults = (visibleIssues: ReadonlyArray): void => { + const visibleKeys = new Set(visibleIssues.map((issue) => issue.key)); + setSelectedKeys((current) => new Set([...current].filter((key) => visibleKeys.has(key)))); + setConversationDisabledKeys((current) => new Set([...current].filter((key) => visibleKeys.has(key)))); + setImportModes((current) => { + const next: Record = {}; + for (const [key, mode] of Object.entries(current)) { + if (visibleKeys.has(key)) { + next[key] = mode; + } + } + return next; + }); + }; + + const handleHideInWorkChange = (enabled: boolean): void => { + hideInWorkRef.current = enabled; + setHideInWork(enabled); + const visibleData = filterVisibleJiraIssues(fetchedResults, enabled); + setResults(visibleData); + pruneIssueStateToVisibleResults(visibleData); + }; + const setImportModeForSelected = (mode: ImportedTaskMode): void => { if (selectedKeys.size === 0) { return; @@ -563,6 +589,21 @@ export const SprintJiraImportModal = ({ aria-label="Jira result limit" /> + + + + @@ -935,6 +976,38 @@ function getOptionLabel( return options.find((option) => option.value === value)?.label ?? value; } +function filterVisibleJiraIssues( + issues: ReadonlyArray, + hideInWork: boolean, +): JiraIssueSearchResult[] { + if (!hideInWork) { + return [...issues]; + } + return issues.filter((issue) => !isInWorkJiraIssue(issue)); +} + +function isInWorkJiraIssue(issue: JiraIssueSearchResult): boolean { + const statusLikeIssue = issue as JiraIssueSearchResult & { + status?: string | null; + statusText?: string | null; + statusName?: string | null; + }; + return [ + statusLikeIssue.state, + statusLikeIssue.status, + statusLikeIssue.statusText, + statusLikeIssue.statusName, + ].some((value) => normalizeJiraStatusText(value) === "in work"); +} + +function normalizeJiraStatusText(value: string | null | undefined): string { + return (value ?? "") + .trim() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .toLowerCase(); +} + function buildImportedTaskPayload( issue: JiraIssueSearchResult, mode: SprintImportedTaskInput["kind"], diff --git a/docs/dashboard/sprint-imports.md b/docs/dashboard/sprint-imports.md index 7fc30eb5eb..807c919ffe 100644 --- a/docs/dashboard/sprint-imports.md +++ b/docs/dashboard/sprint-imports.md @@ -76,12 +76,14 @@ When the GitHub token is empty, GitHub issue search, issue context loading, and ## Jira Issue Import -Use `Import -> Jira Issues` to search Jira with guided filters, multi-select issues, and attach them to the sprint composer. The Jira modal opens on the common search path first: project key, exact issue key lookup, free-text search, status, sort field, sort direction, and a bounded result limit. The default view calls out the normal open-issues, recently-updated-first behavior, active filter summary, visible result count, selected linked count, selected special-task count, and selected issue cards with their current mode. +Use `Import -> Jira Issues` to search Jira with guided filters, multi-select issues, and attach them to the sprint composer. The Jira modal opens on the common search path first: project key, exact issue key lookup, free-text search, status, sort field, sort direction, a bounded result limit, and a `Hide in Work` visibility checkbox. The default view calls out the normal open-issues, recently-updated-first behavior, active filter summary, visible result count, selected linked count, selected special-task count, and selected issue cards with their current mode. Advanced Jira filters are grouped behind an `Advanced Jira filters` toggle. People filters hold assignee and reporter text, classification filters hold issue type, priority, and labels, the updated window uses date inputs, and the explicit JQL override uses a textarea. Project and issue-key inputs are normalized to uppercase, labels use the shared multi-select control, and the advanced JQL override remains optional. When JQL is present, it replaces the guided Jira filters for search construction. Jira results use compact selectable issue cards with source links, Jira-specific metadata, a visible per-card import mode label, `Select all visible`, `Clear selection`, bulk conversation selection, and per-card `Append Conversation` toggles. Selected Jira issues default to linked sprint context and show `Linked issue` until the operator changes mode. When special task creation is available, operators can explicitly switch the selected Jira issues to security or quality task mode before importing. +The `Hide in Work` checkbox is enabled by default and filters the fetched Jira results in the browser by hiding issues whose Jira status text is exactly `In Work` after normalization. It does not change the Jira query, status dropdown, or default open-issues search. Turning the checkbox off immediately shows matching fetched `In Work` issues again; turning it back on prunes hidden issues from selection, conversation toggles, and linked or special-task import modes so they cannot be imported accidentally. The compact filter chips show `Hide in Work` while the visibility filter is active. + The assignee field accepts a Jira user full name, email address, or account ID. It also accepts `me` / `currentUser()` for the connected Jira account and `unassigned` / `empty` for issues without an assignee. The server builds the Jira query from the selected filters, defaults to open issues sorted by recent updates, and uses `Settings -> Integrations -> Jira -> Default project` to prefill the project key when available. Clearing the project key browses all Jira issues the saved credentials can see. The search endpoint also honors an exact issue key, user text, issue type, priority, labels, updated-date windows, sort field, sort direction, and a bounded result limit. Jira import requests use the same trimming, label deduplication, malformed-limit rejection, and pre-client result-limit clamp as repository issue search. Advanced users can open the JQL override and replace the guided query entirely; when JQL is present, it overrides the other filters. @@ -100,7 +102,7 @@ Jira dashboard and MCP importer workflows require those saved Jira settings. The Selected Jira issues are loaded through the same prompt-context path as GitHub/GitLab imports. The sprint prompt receives the Jira description and, when `Append Conversation` is enabled, Jira comments. Imported Jira cards are persisted as linked sprint issues with provider `jira`, host extracted from the Jira URL, project key, repository fallback, parsed issue number from keys such as `OPS-42`, issue key, labels, assignees, status, source URL, and the selected conversation flag. The import result cards also surface Jira issue type, priority, reporter, assignee, labels, status, updated timestamps, and a description preview when Jira returns those fields. -When Jira issues are imported as linked sprint issues, Code UX attempts to move each linked Jira issue through the configured import transition. The default is enabled and uses `In Work`. Transition lookup is case-insensitive. Import transition failures are non-destructive: the linked issue remains persisted locally, the dashboard or MCP result includes a warning with the Jira key and failure message, and the failure is logged for operators. This import-time transition is separate from sprint-completion auto-close and does not change the `Done` close transition behavior. +When Jira issues are imported as linked sprint issues, Code UX attempts to move each linked Jira issue through the configured import transition. The default is enabled and uses `In Work`. Transition lookup is case-insensitive. This import transition setting is separate from the Jira modal's `Hide in Work` checkbox: the checkbox only controls which fetched issues are visible and selectable before import, while the transition setting controls what Code UX asks Jira to do after linked issues are imported. Import transition failures are non-destructive: the linked issue remains persisted locally, the dashboard or MCP result includes a warning with the Jira key and failure message, and the failure is logged for operators. This import-time transition is separate from sprint-completion auto-close and does not change the `Done` close transition behavior. When operators mark selected Jira issues as security or quality task mode, the dashboard emits imported task payloads instead of linked issue contexts. Those special tasks are created directly on the sprint and bypass planning prose, while ordinary Jira issues still become linked issues that feed the sprint prompt and linked issue records. Jira issue labels, issue type, priority, title, or description text do not automatically convert an issue into a special task. diff --git a/tests/dashboard/v2/components/sprints/SprintJiraImportModal.test.tsx b/tests/dashboard/v2/components/sprints/SprintJiraImportModal.test.tsx index 305ee07fb9..ad3ba6f93d 100644 --- a/tests/dashboard/v2/components/sprints/SprintJiraImportModal.test.tsx +++ b/tests/dashboard/v2/components/sprints/SprintJiraImportModal.test.tsx @@ -44,6 +44,14 @@ const baseIssue = { sourceProvider: "jira" as const, }; +const inWorkIssue = { + ...baseIssue, + key: "OPS-77", + title: "Already being handled", + url: "https://acme.atlassian.net/browse/OPS-77", + state: "In Work", +}; + describe("SprintJiraImportModal", () => { it("loads the default project key and uses guided Jira filters", async () => { vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({ @@ -86,14 +94,83 @@ describe("SprintJiraImportModal", () => { expect(screen.getByRole("button", { name: /^search$/i })).toBeEnabled(); expect(screen.getByRole("button", { name: /import issues disabled until jira issues are selected/i })).toBeDisabled(); expect(screen.getByRole("button", { name: /advanced jira filters/i })).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByRole("checkbox", { name: /hide in work/i })).toBeChecked(); expect(document.getElementById("jira-import-advanced-filters")).toHaveClass("hidden"); expect(screen.getAllByText(/Default: open Jira issues, recently updated first/i).length).toBeGreaterThan(0); + expect(screen.getByLabelText("Active Jira filters")).toHaveTextContent(/Visibility\s*Hide in Work/i); expect([...document.querySelectorAll("[aria-live='polite']")].some((node) => ( node.textContent?.replace(/\s+/g, " ").includes("0 linked, 0 special") ))).toBe(true); expect(screen.getByRole("button", { name: /import jira backlog/i })).toHaveAttribute("aria-pressed", "false"); }); + it("hides Jira issues already in work by default", async () => { + vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({ + settings: { jira: { defaultProject: "OPS" } }, + } as never); + vi.mocked(searchJiraIssues).mockResolvedValue([baseIssue, inWorkIssue]); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Jira backlog")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Already being handled")).not.toBeInTheDocument(); + expect(screen.getByText(/1 visible result/i)).toBeInTheDocument(); + }); + + it("shows in-work Jira issues when Hide in Work is unchecked", async () => { + vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({ + settings: { jira: { defaultProject: "OPS" } }, + } as never); + vi.mocked(searchJiraIssues).mockResolvedValue([baseIssue, inWorkIssue]); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Jira backlog")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("checkbox", { name: /hide in work/i })); + + expect(screen.getByText("Already being handled")).toBeInTheDocument(); + expect(screen.getByText(/2 visible results/i)).toBeInTheDocument(); + }); + + it("prunes selected in-work Jira issues when Hide in Work is re-enabled", async () => { + vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({ + settings: { jira: { defaultProject: "OPS" } }, + } as never); + vi.mocked(searchJiraIssues).mockResolvedValue([baseIssue, inWorkIssue]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Import Jira backlog")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("checkbox", { name: /hide in work/i })); + fireEvent.click(screen.getByText("Already being handled")); + expect(screen.getByText(/1 selected issue will be imported\. 1 linked, 0 special tasks\./i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /quality task/i })); + expect(screen.getByText(/1 selected issue will be imported\. 0 linked, 1 special task\./i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("checkbox", { name: /hide in work/i })); + + expect(screen.queryByText("Already being handled")).not.toBeInTheDocument(); + expect(screen.getByText("No issues selected.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /import issues disabled until jira issues are selected/i })).toBeDisabled(); + }); + it("supports exact keys, user filters, labels, date windows, sort controls, and JQL override", async () => { vi.mocked(fetchProjectEffectiveSettings).mockResolvedValue({ settings: { jira: { defaultProject: "OPS" } }, From e524839f8cbdcbc7078e76063aed9a5c9e290551 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:22:23 +0000 Subject: [PATCH 2/6] feat(task T01): implement via codex --- docs-web/user/dashboard/chat.md | 2 + docs/architecture/repository-map.md | 2 + docs/dashboard/dashboard-guide.md | 3 ++ .../lifecycle/dashboard-lifecycle-service.ts | 2 +- src/contracts/connection-chat-types.ts | 1 + .../connection-chat-repository.ts | 18 +++++-- .../conversation-query-utils.ts | 22 +++++++++ src/server/conversation-routes.ts | 4 +- src/server/dashboard-server.ts | 2 +- src/server/request-parsers.ts | 9 ++++ src/services/chat-reply-prompt.ts | 20 +++++++- src/services/chat-thread-runtime-service.ts | 47 ++++++++++++++++++- .../connection-chat-repository.test.ts | 35 ++++++++++++++ .../backend/server/dashboard-chat-api.test.ts | 21 ++++++++- .../chat-thread-runtime-service.test.ts | 33 +++++++++++++ 15 files changed, 209 insertions(+), 12 deletions(-) diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index fcfa872042..f0455cee26 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -17,6 +17,8 @@ A *thread* is a persistent conversation with an agent. Each thread has: - A **routing config** — which agent preset and provider answers when you post a message. - A **session** — the underlying provider session. Sessions can be **compacted** (summarised) to fit within context limits. +New dashboard chat threads derive an 8-word-or-less title from the first visible user message. Code UX stores the title in sqlite and mirrors it to `.code-ux/conversations//session-title.md` inside the project checkout; manual title edits update both places. + To start a new thread, click **+ New thread**. To change the responding agent, open the thread header dropdown and pick from the list of agent presets defined for this project. Each post triggers a routed invocation: the dashboard records the request, dispatches it to the chosen provider via the worker assignment service (routed through the `dashboard_reply` invocation type), and streams the reply back into the thread. diff --git a/docs/architecture/repository-map.md b/docs/architecture/repository-map.md index 38d06ff545..a88b4b139f 100644 --- a/docs/architecture/repository-map.md +++ b/docs/architecture/repository-map.md @@ -118,6 +118,8 @@ backup files appear there. - project/home/default agent markdown mirrors such as `planning_agent.md` and `worker.md` - `sprints/` - Runtime sprint plans and generated subtask markdown files. +- `conversations//session-title.md` + - Project-local dashboard chat session title mirror. New dashboard chat threads derive a concise title from the first visible user message, and manual title edits update this file alongside the sqlite thread record. ## Documentation (`docs/`) diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 22fe1ab76f..75e6cfe629 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -94,6 +94,8 @@ Project management: - Lists project conversation threads - `POST /api/projects/:projectId/conversations/threads` - Creates a new project conversation thread +- `PATCH /api/conversations/threads/:threadId` + - Updates a conversation thread's connection, runtime state, or non-empty title. Title changes also mirror to `.code-ux/conversations//session-title.md` in the project checkout. - `POST /api/conversations/threads/:threadId/compact` - Compacts a thread's conversation history into a stored handoff summary - `POST /api/conversations/threads/:threadId/cancel` @@ -478,6 +480,7 @@ Legacy runtime: - The first built-in role is `Planning agent`, which is editable under Agents like any other DB-backed agent - `Settings > Sprint & Git` now includes the QA controls immediately below `Merge Gates & Autofix`, with per-trigger multi-select agent assignment across all project agents, QA-labeled presets floated to the top, the same project-scope behavior preserved for local QA edits, and the persisted settings path still anchored at `agents.qualityAssurance`. Leaving a trigger with no custom agents selected clearly uses the built-in QA fallback without saving placeholder preset ids. - Chat page is DB-backed and stores project conversation threads/messages in sqlite +- New dashboard chat threads derive an 8-word-or-less title from the first visible user message and mirror that title to `.code-ux/conversations//session-title.md`; hidden/internal messages do not drive user-facing titles. - Chat page now provides a `Threads / Invocations` toggle to switch between human conversation threads and read-only execution invocations. - Chat page UI is redesigned with animated identities, structured widgets for rich messages, and automatic worker pickup derived from active project routing. - Chat page logs invocation activity explicitly in the background, providing observable execution artifacts directly in the chat view. diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts index 1acba6b284..cc61c277a0 100644 --- a/src/app/lifecycle/dashboard-lifecycle-service.ts +++ b/src/app/lifecycle/dashboard-lifecycle-service.ts @@ -674,7 +674,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise instructionFileService.writeInstructionFile(projectId, fileId, content), listConversationThreads: (projectId) => deps.connectionChatRepository.listThreads(projectId), createConversationThread: (projectId, input) => deps.connectionChatRepository.createThread(projectId, input), - updateConversationThread: (threadId, input) => deps.connectionChatRepository.updateThread(threadId, input), + updateConversationThread: (threadId, input) => deps.chatThreadRuntimeService.updateConversationThread(threadId, input), updateThreadRoute: (threadId, input) => deps.chatThreadRuntimeService.updateThreadRoute(threadId, input), compactThreadSession: (threadId) => deps.chatThreadRuntimeService.compactThreadSession(threadId), cancelThreadTurn: (threadId) => deps.chatThreadRuntimeService.cancelInFlightTurn(threadId), diff --git a/src/contracts/connection-chat-types.ts b/src/contracts/connection-chat-types.ts index 186b7f88b6..12b28612fb 100644 --- a/src/contracts/connection-chat-types.ts +++ b/src/contracts/connection-chat-types.ts @@ -172,6 +172,7 @@ export interface CreateDashboardConversationMessageInput { } export interface UpdateConversationThreadInput { + title?: string; connectionId?: string | null; runtimeState?: ConversationRuntimeState | null; } diff --git a/src/repositories/connection-chat-repository.ts b/src/repositories/connection-chat-repository.ts index 6372fad1ef..d1b9e19a1b 100644 --- a/src/repositories/connection-chat-repository.ts +++ b/src/repositories/connection-chat-repository.ts @@ -22,6 +22,7 @@ import type { DashboardRealtimeService } from "../services/dashboard-realtime-se import { WorkerEndpointRepository } from "./worker-endpoint-repository.js"; import { HIDDEN_INTERNAL_VISIBILITY, + deriveConversationThreadTitleFromFirstMessage, visibleConversationMessageFilter, } from "./connection-chat/conversation-query-utils.js"; import { @@ -440,6 +441,10 @@ export class ConnectionChatRepository { : input.connectionId === null ? null : input.connectionId.trim(); + const normalizedTitle = input.title === undefined ? thread.title : input.title.trim(); + if (!normalizedTitle) { + throw new Error("Thread title must be a non-empty string."); + } if (normalizedConnectionId) { const connection = this.requireConnection(normalizedConnectionId); @@ -452,10 +457,11 @@ export class ConnectionChatRepository { this.runInTransaction(() => { this.db.prepare(` UPDATE conversation_threads - SET connection_id = ?, runtime_state_json = ?, updated_at = ? + SET connection_id = ?, title = ?, runtime_state_json = ?, updated_at = ? WHERE id = ? `).run( normalizedConnectionId || null, + normalizedTitle, input.runtimeState !== undefined ? (input.runtimeState ? JSON.stringify(input.runtimeState) : null) : (thread.runtimeState ? JSON.stringify(thread.runtimeState) : null), now, thread.id @@ -492,10 +498,17 @@ export class ConnectionChatRepository { postDashboardMessage(projectId: string, input: CreateDashboardConversationMessageInput): ConversationMessageRecord { requireRecord(this.db.prepare('SELECT id FROM projects WHERE id = ?').get(projectId), "Project", projectId); + const fallbackTitle = input.title?.trim() || `Project Chat ${new Date().toISOString().slice(0, 16)}`; + const hiddenMessage = isHiddenConversationMessage(input.metadata); + const generatedTitle = input.title?.trim() + ? fallbackTitle + : hiddenMessage + ? fallbackTitle + : deriveConversationThreadTitleFromFirstMessage(input.bodyMarkdown, fallbackTitle); const thread = input.threadId ? requireConversationThreadQuery(this.db, input.threadId) : this.createThread(projectId, { - title: input.title?.trim() || `Project Chat ${new Date().toISOString().slice(0, 16)}`, + title: generatedTitle, connectionId: input.connectionId ?? undefined, }); @@ -506,7 +519,6 @@ export class ConnectionChatRepository { const preferredConnectionId = thread.connectionId || input.connectionId || null; const now = new Date().toISOString(); const messageId = randomUUID(); - const hiddenMessage = isHiddenConversationMessage(input.metadata); this.runInTransaction(() => { if (!thread.connectionId && preferredConnectionId) { diff --git a/src/repositories/connection-chat/conversation-query-utils.ts b/src/repositories/connection-chat/conversation-query-utils.ts index 875898b5af..1c65b60f23 100644 --- a/src/repositories/connection-chat/conversation-query-utils.ts +++ b/src/repositories/connection-chat/conversation-query-utils.ts @@ -39,11 +39,33 @@ export const DEFAULT_CONVERSATION_THREAD_LIST_LIMIT = 500; export const MAX_CONVERSATION_THREAD_LIST_LIMIT = 500; export const DEFAULT_CONVERSATION_MESSAGE_LIST_LIMIT = 5000; export const MAX_CONVERSATION_MESSAGE_LIST_LIMIT = 5000; +export const MAX_CONVERSATION_THREAD_TITLE_WORDS = 8; export function visibleConversationMessageFilter(alias: string): string { return `(COALESCE(json_extract(${alias}.metadata_json, '$.internalVisibility'), '') != '${HIDDEN_INTERNAL_VISIBILITY}')`; } +export function deriveConversationThreadTitleFromFirstMessage(bodyMarkdown: string, fallbackTitle: string): string { + const normalized = bodyMarkdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]*)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, " ") + .replace(/^[ \t]*(#{1,6}|[-*+]|\d+[.)]|>\s?|\[[ xX]])[ \t]*/gm, " ") + .replace(/[*_~|[\](){}#>\\]/g, " ") + .replace(/[\u0000-\u001F\u007F]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const words = normalized.match(/[A-Za-z0-9][A-Za-z0-9'_-]*/g) || []; + if (words.length === 0) { + return fallbackTitle; + } + + return words.slice(0, MAX_CONVERSATION_THREAD_TITLE_WORDS).join(" "); +} + export function mapThreadRow(row: ThreadRow): ConversationThreadRecord { return { id: row.id, diff --git a/src/server/conversation-routes.ts b/src/server/conversation-routes.ts index 463ba8111a..2cdce79cec 100644 --- a/src/server/conversation-routes.ts +++ b/src/server/conversation-routes.ts @@ -17,8 +17,8 @@ export function registerConversationRoutes(app: Express, options: DashboardDepen ); })); - app.patch("/api/conversations/threads/:threadId", syncRoute((req, res) => { - res.json(options.updateConversationThread( + app.patch("/api/conversations/threads/:threadId", asyncRoute(async (req, res) => { + res.json(await options.updateConversationThread( requireTrimmedString(req.params.threadId, "threadId"), parseUpdateConversationThreadInput(req.body) )); diff --git a/src/server/dashboard-server.ts b/src/server/dashboard-server.ts index 567ea31f0e..9d2c4e6ef4 100644 --- a/src/server/dashboard-server.ts +++ b/src/server/dashboard-server.ts @@ -241,7 +241,7 @@ export interface DashboardServerOptions { writeInstructionFile: (projectId: string, fileId: string, content: string) => Promise | InstructionFileContent; listConversationThreads: (projectId: string) => ConversationThreadRecord[]; createConversationThread: (projectId: string, input: CreateConversationThreadInput) => ConversationThreadRecord; - updateConversationThread: (threadId: string, input: UpdateConversationThreadInput) => ConversationThreadRecord; + updateConversationThread: (threadId: string, input: UpdateConversationThreadInput) => Promise | ConversationThreadRecord; updateThreadRoute: (threadId: string, input: UpdateConversationThreadRouteInput) => ConversationThreadRecord; compactThreadSession: (threadId: string) => Promise | ConversationThreadRecord; cancelThreadTurn?: (threadId: string) => Promise<{ cancelled: boolean }> | { cancelled: boolean }; diff --git a/src/server/request-parsers.ts b/src/server/request-parsers.ts index 5509bb4267..1c7dfd73b7 100644 --- a/src/server/request-parsers.ts +++ b/src/server/request-parsers.ts @@ -617,7 +617,16 @@ export function parseUpdateConversationThreadInput(body: unknown): UpdateConvers throw new Error("Invalid input: body must be an object"); } const typedBody = body as Record; + const title = typedBody.title === undefined + ? undefined + : typeof typedBody.title === "string" + ? typedBody.title.trim() + : ""; + if (typedBody.title !== undefined && !title) { + throw new Error("Thread title must be a non-empty string."); + } return { + title, connectionId: typeof typedBody.connectionId === "string" ? typedBody.connectionId.trim() : (typedBody.connectionId === null ? null : undefined), runtimeState: typedBody.runtimeState as UpdateConversationThreadInput["runtimeState"], }; diff --git a/src/services/chat-reply-prompt.ts b/src/services/chat-reply-prompt.ts index ebeaff1ff6..623aa653f5 100644 --- a/src/services/chat-reply-prompt.ts +++ b/src/services/chat-reply-prompt.ts @@ -114,6 +114,14 @@ function buildMcpNativeOutputInstructions(): string { ].join("\n"); } +function buildSessionTitleInstructions(threadTitle: string | undefined): string { + return [ + "Session Title File: `.code-ux/conversations//session-title.md`", + threadTitle ? `Current Session Title: ${threadTitle}` : "", + "Keep this file updated with an 8-word maximum descriptive title on the first user message and every 20 chat invocations.", + ].filter((line) => line.trim().length > 0).join("\n"); +} + export function buildChatReplayPrompt(args: { projectId: string; repoPath: string; @@ -173,6 +181,7 @@ export function buildChatReplayPrompt(args: { const knowledgeSection = args.knowledgeManifest && args.knowledgeManifest.trim() ? `## KNOWLEDGE BASE\n\n${args.knowledgeManifest.trim()}` : ""; + const currentThreadTitle = args.threadTitle || args.thread.title; return [ args.workerInstructions ? `## WORKER INSTRUCTIONS\n\n${args.workerInstructions}` : "", @@ -183,7 +192,8 @@ export function buildChatReplayPrompt(args: { `Project: ${args.projectName}`, `Repo Path: ${args.repoPath}`, `Thread ID: ${args.thread.id}`, - args.threadTitle || args.thread.title ? `Thread Title: ${args.threadTitle || args.thread.title}` : "", + currentThreadTitle ? `Thread Title: ${currentThreadTitle}` : "", + buildSessionTitleInstructions(currentThreadTitle), "", knowledgeSection, "", @@ -204,7 +214,12 @@ export function buildChatReplayPrompt(args: { ].filter((part) => part.trim().length > 0).join("\n"); } -export function buildChatContinuationPrompt(message: ConversationMessageRecord, pendingAction?: ConversationRuntimeState["pendingManagementAction"], mcpAvailable?: boolean): string { +export function buildChatContinuationPrompt( + message: ConversationMessageRecord, + pendingAction?: ConversationRuntimeState["pendingManagementAction"], + mcpAvailable?: boolean, + threadTitle?: string, +): string { const pendingActionContext = pendingAction ? [ "## PENDING ACTION CONTEXT", "You previously proposed the following management action which requires user approval:", @@ -218,6 +233,7 @@ export function buildChatContinuationPrompt(message: ConversationMessageRecord, return [ pendingActionContext, "## DASHBOARD CHAT CONTINUATION", + buildSessionTitleInstructions(threadTitle), "The dashboard user's latest message is below.", "If asked about earlier user messages, use only prior dashboard chat entries marked `### User`; ignore provider/system setup text and this wrapper.", "", diff --git a/src/services/chat-thread-runtime-service.ts b/src/services/chat-thread-runtime-service.ts index 8b933dee7f..2f9369b199 100644 --- a/src/services/chat-thread-runtime-service.ts +++ b/src/services/chat-thread-runtime-service.ts @@ -1,4 +1,6 @@ import type { DashboardSettings, DashboardSettingsScope, ProviderId, QwenModelProviderSettings, Subtask } from "../contracts/app-types.js"; +import * as fs from "fs/promises"; +import * as path from "path"; import type { ConnectionChatRepository } from "../repositories/connection-chat-repository.js"; import type { ProjectWorkerAssignmentRepository } from "../repositories/project-worker-assignment-repository.js"; import type { ExecutionRepository } from "../repositories/execution-repository.js"; @@ -7,9 +9,10 @@ import type { AgentPresetSyncService } from "./agent-preset-sync-service.js"; import type { ProjectManagementRepository } from "../repositories/project-management-repository.js"; import type { IProviderRunner } from "../infrastructure/providers/cli/provider-runner.js"; import type { Logger } from "../shared/logging/logger.js"; -import type { ConversationCompactionSummary, CreateDashboardConversationMessageInput, ConversationThreadRecord, ConversationMessageRecord, ConversationRuntimeState, UpdateConversationThreadRouteInput } from "../contracts/connection-chat-types.js"; +import type { ConversationCompactionSummary, CreateDashboardConversationMessageInput, ConversationThreadRecord, ConversationMessageRecord, ConversationRuntimeState, UpdateConversationThreadInput, UpdateConversationThreadRouteInput } from "../contracts/connection-chat-types.js"; import { buildProviderPrompt } from "./cli-workflow-utils.js"; import { resolveEffectiveModel } from "./provider-execution-service.js"; +import { getRepoCodeUxDir, getRepoCodeUxPath } from "../shared/config/code-ux-paths.js"; import { buildChatCompactionPrompt, buildChatContinuationPrompt, @@ -78,6 +81,28 @@ const resolveEffectiveDefaultBranch = ( || "main" ); +function getThreadSessionTitlePath(repoPath: string, threadId: string): string { + const safeThreadId = threadId.replace(/[^A-Za-z0-9_.-]/g, "-"); + const codeUxDir = path.resolve(getRepoCodeUxDir(repoPath)); + const titlePath = path.resolve(getRepoCodeUxPath(repoPath, "conversations", safeThreadId, "session-title.md")); + const relativeTitlePath = path.relative(codeUxDir, titlePath); + + if (relativeTitlePath.startsWith("..") || path.isAbsolute(relativeTitlePath)) { + throw new Error("Refusing to write session title outside the project .code-ux directory."); + } + + return titlePath; +} + +async function writeThreadSessionTitleFile(repoPath: string, threadId: string, title: string): Promise { + if (!title.trim()) { + throw new Error("Thread title must be a non-empty string."); + } + const titlePath = getThreadSessionTitlePath(repoPath, threadId); + await fs.mkdir(path.dirname(titlePath), { recursive: true }); + await fs.writeFile(titlePath, `${title.trim()}\n`, { encoding: "utf8" }); +} + export class ChatThreadRuntimeService { private readonly inFlightTurns = new Map(); @@ -193,6 +218,18 @@ export class ChatThreadRuntimeService { }); } + public async updateConversationThread(threadId: string, input: UpdateConversationThreadInput): Promise { + const updatedThread = this.deps.connectionChatRepository.updateThread(threadId, input); + if (input.title !== undefined) { + const project = this.deps.projectManagementRepository.getProject(updatedThread.projectId); + if (!project) { + throw new Error(`Project not found: ${updatedThread.projectId}`); + } + await writeThreadSessionTitleFile(project.baseDir, updatedThread.id, updatedThread.title); + } + return updatedThread; + } + public async compactThreadSession(threadId: string): Promise { const thread = this.deps.connectionChatRepository.getThread(threadId); const project = this.deps.projectManagementRepository.getProject(thread.projectId); @@ -245,6 +282,12 @@ export class ChatThreadRuntimeService { const userMessage = this.deps.connectionChatRepository.postDashboardMessage(projectId, input); const thread = this.deps.connectionChatRepository.getThread(userMessage.threadId); if (!thread) throw new Error("Thread not found"); + if (!input.threadId && typeof thread.title === "string" && thread.title.trim()) { + const project = this.deps.projectManagementRepository.getProject(projectId); + if (project) { + await writeThreadSessionTitleFile(project.baseDir, thread.id, thread.title); + } + } const existingTurn = this.inFlightTurns.get(thread.id); if (existingTurn) { @@ -446,7 +489,7 @@ export class ChatThreadRuntimeService { knowledgeManifest, }); } else { - promptContent = buildChatContinuationPrompt(latestMessage, pendingAction, mcpAvailable); + promptContent = buildChatContinuationPrompt(latestMessage, pendingAction, mcpAvailable, thread.title); continueSessionId = runtimeState.sessionIds![0]; } diff --git a/tests/backend/repositories/connection-chat-repository.test.ts b/tests/backend/repositories/connection-chat-repository.test.ts index c3f27c73a5..4ec83b114d 100644 --- a/tests/backend/repositories/connection-chat-repository.test.ts +++ b/tests/backend/repositories/connection-chat-repository.test.ts @@ -865,11 +865,14 @@ describe("ConnectionChatRepository", () => { const updatedState = { ...runtimeState, replayRequired: false }; const updatedThread = connectionRepository.updateThread(thread.id, { + title: "Updated Test Thread", runtimeState: updatedState, }); + expect(updatedThread.title).toBe("Updated Test Thread"); expect(updatedThread.runtimeState).toEqual(updatedState); const rehydratedThreads = connectionRepository.listThreads(project.id); + expect(rehydratedThreads[0].title).toBe("Updated Test Thread"); expect(rehydratedThreads[0].runtimeState).toEqual(updatedState); const messageMetadata = { testKey: "testValue", numericKey: 123 }; @@ -885,6 +888,38 @@ describe("ConnectionChatRepository", () => { expect(messages[0].metadata).toEqual(messageMetadata); }); + it("derives a concise thread title from the first visible dashboard message", async () => { + const { projectRepository, connectionRepository } = await createRepositories(); + const project = projectRepository.createProject({ + name: "Title Project", + sourceType: "local", + sourceRef: "/tmp/title-project", + }); + + const message = connectionRepository.postDashboardMessage(project.id, { + bodyMarkdown: "### Fix **critical** dashboard routing bug with worker retry state after compaction", + }); + + const thread = connectionRepository.getThread(message.threadId); + expect(thread.title).toBe("Fix critical dashboard routing bug with worker retry"); + expect(thread.title.split(/\s+/)).toHaveLength(8); + }); + + it("falls back to the timestamp title when the first dashboard message has no useful words", async () => { + const { projectRepository, connectionRepository } = await createRepositories(); + const project = projectRepository.createProject({ + name: "Fallback Title Project", + sourceType: "local", + sourceRef: "/tmp/fallback-title-project", + }); + + const message = connectionRepository.postDashboardMessage(project.id, { + bodyMarkdown: "```ts\n!!!\n```\n---", + }); + + const thread = connectionRepository.getThread(message.threadId); + expect(thread.title).toMatch(/^Project Chat \d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); describe("Repository single entity operations", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/tests/backend/server/dashboard-chat-api.test.ts b/tests/backend/server/dashboard-chat-api.test.ts index 991e3c119a..5903ea918d 100644 --- a/tests/backend/server/dashboard-chat-api.test.ts +++ b/tests/backend/server/dashboard-chat-api.test.ts @@ -130,7 +130,7 @@ describe("Dashboard Chat API", () => { deleteAgentPreset: () => {}, listConversationThreads: () => [], createConversationThread: () => ({} as any), - updateConversationThread: () => ({} as any), + updateConversationThread: (threadId, input) => chatThreadRuntimeService.updateConversationThread(threadId, input), deleteConversationThread: () => {}, listConversationMessages: () => [], postConversationMessage: () => ({} as any), @@ -152,6 +152,24 @@ describe("Dashboard Chat API", () => { serversToClose.push(handle.server); const baseUrl = `http://127.0.0.1:${handle.port}`; + const titleResponse = await fetch(`${baseUrl}/api/conversations/threads/${thread.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Manual Session Title", + runtimeState: { routeKind: "virtual", replayRequired: false }, + }), + }); + expect(titleResponse.status).toBe(200); + const titledThread = await titleResponse.json() as any; + expect(titledThread.title).toBe("Manual Session Title"); + expect(titledThread.runtimeState).toMatchObject({ + routeKind: "virtual", + replayRequired: false, + }); + await expect(fs.readFile(path.join(project.baseDir, ".code-ux", "conversations", thread.id, "session-title.md"), "utf8")) + .resolves.toBe("Manual Session Title\n"); + const routeResponse = await fetch(`${baseUrl}/api/conversations/threads/${thread.id}/route`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -184,6 +202,7 @@ describe("Dashboard Chat API", () => { model: "gpt-4", }, }); + expect(compactedThread.title).toBe("Manual Session Title"); }); it("validates worker availability and virtual provider configuration", async () => { diff --git a/tests/backend/services/chat-thread-runtime-service.test.ts b/tests/backend/services/chat-thread-runtime-service.test.ts index 9bd69fccce..6afa6a0644 100644 --- a/tests/backend/services/chat-thread-runtime-service.test.ts +++ b/tests/backend/services/chat-thread-runtime-service.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; import { ChatThreadRuntimeService } from "../../../src/services/chat-thread-runtime-service.js"; describe("ChatThreadRuntimeService", () => { @@ -67,6 +70,36 @@ describe("ChatThreadRuntimeService", () => { await expect(service.postMessage("p1", { bodyMarkdown: "hello" })).rejects.toThrow("Thread not found"); }); + it("persists a new chat thread title to the project session-title file", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-chat-title-")); + try { + deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ id: "msg-title", threadId: "t1", bodyMarkdown: "Please fix the dashboard route title behavior" }); + deps.connectionChatRepository.getThread.mockReturnValue({ + id: "t1", + projectId: "p1", + connectionId: null, + title: "Please fix the dashboard route title behavior", + runtimeState: {}, + }); + deps.projectManagementRepository.getProject.mockReturnValue({ id: "p1", name: "proj", baseDir: dir }); + deps.taskService.resolveInvocationProvider.mockReturnValue({ + provider: "codex", + providers: { codex: { model: "gpt-5.3-codex", apiKey: "codex-key" } }, + }); + deps.connectionChatRepository.listMessages.mockReturnValue([ + { authorType: "dashboard_user", bodyMarkdown: "Please fix the dashboard route title behavior" }, + ]); + deps.chatManagementActionService.processManagementAction.mockResolvedValue({ replyMarkdown: "reply", action: null, approvalRequired: false }); + + await service.postMessage("p1", { bodyMarkdown: "Please fix the dashboard route title behavior" }); + + await expect(fs.readFile(path.join(dir, ".code-ux", "conversations", "t1", "session-title.md"), "utf8")) + .resolves.toBe("Please fix the dashboard route title behavior\n"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it("runs virtual provider and replays history on provider switch using chatManagementActionService", async () => { deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ id: "msg-2", threadId: "t1", bodyMarkdown: "hello" }); deps.connectionChatRepository.getThread.mockReturnValue({ From 914e06a07c35fcf5aa7ca38f640fc3e9d4569411 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:27:44 +0000 Subject: [PATCH 3/6] feat(task T03): implement via codex --- dashboard/src/v2/components/TopNav.tsx | 36 ++------- .../architecture/dashboard-architecture.md | 2 +- docs/dashboard/dashboard-guide.md | 2 +- .../design-system-shell-navigation.md | 4 +- docs/dashboard/mobile-responsiveness.md | 4 +- .../v2/top-nav-browser-menu.test.tsx | 77 +++++++++++++++++++ 6 files changed, 88 insertions(+), 37 deletions(-) diff --git a/dashboard/src/v2/components/TopNav.tsx b/dashboard/src/v2/components/TopNav.tsx index 617218bf33..1a4ccae8b4 100644 --- a/dashboard/src/v2/components/TopNav.tsx +++ b/dashboard/src/v2/components/TopNav.tsx @@ -318,7 +318,7 @@ export const TopNav: FunctionComponent = ({ 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]); @@ -435,7 +435,7 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, /> Use arrow keys to navigate options. -
+
{filteredProjects.length === 0 && (
No projects found. @@ -514,7 +514,7 @@ export const TopNav: FunctionComponent = ({ 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) { @@ -563,38 +563,12 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, /> Use arrow keys to navigate options.
-
- {filteredSprints.length === 0 && !sprintFilter.toLowerCase().includes('all') && ( +
+ {filteredSprints.length === 0 && (
No sprints found.
)} - {(sprintFilter === '' || 'all sprints'.includes(sprintFilter.toLowerCase())) && ( - - )} {filteredSprints.map((sprint) => ( + +
+
+ {renameError && titleErrorId && ( + + )} +
+ ) : ( +
+

+ {thread?.title || "No Thread Selected"} +

+ {thread && ( + + )} +
+ )}
diff --git a/dashboard/src/v2/components/chat/ThreadListCard.tsx b/dashboard/src/v2/components/chat/ThreadListCard.tsx index 5d8179ab2f..9ace717c08 100644 --- a/dashboard/src/v2/components/chat/ThreadListCard.tsx +++ b/dashboard/src/v2/components/chat/ThreadListCard.tsx @@ -103,7 +103,7 @@ export const ThreadListCard: FunctionComponent<{
{/* Title */} -

+

{thread.title}

diff --git a/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx b/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx index d4f23d1046..b09c1ec2b1 100644 --- a/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx +++ b/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx @@ -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); @@ -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(), @@ -109,6 +111,10 @@ vi.mock('../../../hooks/use-project-effective-settings.js', () => ({ })); describe('ChatPage Accessibility', () => { + afterEach(() => { + cleanup(); + }); + beforeEach(() => { vi.clearAllMocks(); mocks.reducedMotion.value = false; @@ -125,6 +131,7 @@ describe('ChatPage Accessibility', () => { input: "Ship it", error: "Test error", feedback: { status: "idle", message: null }, + handleRenameThread: vi.fn(() => Promise.resolve()), }; }); @@ -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', () => { @@ -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 = () => ( + + + + ); + 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, diff --git a/dashboard/src/v2/hooks/use-chat-page-data.ts b/dashboard/src/v2/hooks/use-chat-page-data.ts index 1c5f8ee315..b1d0dfe16a 100644 --- a/dashboard/src/v2/hooks/use-chat-page-data.ts +++ b/dashboard/src/v2/hooks/use-chat-page-data.ts @@ -230,6 +230,7 @@ export const useChatPageData = (options?: { composerRef?: RefObject => { + 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; @@ -509,6 +526,7 @@ export const useChatThreadData = (options: { handleSend, navigateHistory, handleDeleteThread, + handleRenameThread, feedback, clearFeedback, isConfirmOpen, diff --git a/dashboard/src/v2/lib/__tests__/connection-api.test.ts b/dashboard/src/v2/lib/__tests__/connection-api.test.ts new file mode 100644 index 0000000000..f6f34b8b93 --- /dev/null +++ b/dashboard/src/v2/lib/__tests__/connection-api.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchJson } from "../../../lib/api/fetch-json.js"; +import { updateConversationThread } from "../connection-api.js"; + +vi.mock("../../../lib/api/fetch-json.js", () => ({ + fetchJson: vi.fn(), +})); + +describe("connection-api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("patches a conversation thread title without route fields", async () => { + const updatedThread = { + id: "thread-1", + projectId: "project-1", + connectionId: null, + scope: "project", + title: "Renamed Session", + status: "open", + createdAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:01:00.000Z", + messageCount: 1, + pendingMessageCount: 0, + lastMessageAt: null, + lastMessagePreview: null, + }; + vi.mocked(fetchJson).mockResolvedValueOnce(updatedThread); + + const result = await updateConversationThread("thread-1", { title: "Renamed Session" }); + + expect(result).toBe(updatedThread); + expect(fetchJson).toHaveBeenCalledWith( + "/api/conversations/threads/thread-1", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Renamed Session" }), + }, + ); + }); +}); diff --git a/dashboard/src/verify-chat-header.tsx b/dashboard/src/verify-chat-header.tsx index 92cbd0b15e..d5d1e5df81 100644 --- a/dashboard/src/verify-chat-header.tsx +++ b/dashboard/src/verify-chat-header.tsx @@ -36,6 +36,7 @@ const App = () => ( thread={mockThreadActive} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={() => Promise.resolve()} isCompacting={false} isCancelling={false} /> @@ -45,6 +46,7 @@ const App = () => ( thread={mockThreadReplay} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={() => Promise.resolve()} isCompacting={false} isCancelling={false} /> @@ -54,6 +56,7 @@ const App = () => ( thread={mockThreadNew} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={() => Promise.resolve()} isCompacting={false} isCancelling={false} /> diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index f0455cee26..55cff1399f 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -19,6 +19,8 @@ A *thread* is a persistent conversation with an agent. Each thread has: New dashboard chat threads derive an 8-word-or-less title from the first visible user message. Code UX stores the title in sqlite and mirrors it to `.code-ux/conversations//session-title.md` inside the project checkout; manual title edits update both places. +To rename a thread, use the edit control beside the active thread title. The inline editor supports pointer and keyboard workflows: Enter saves, Escape cancels, explicit save/cancel buttons are available, and empty titles are rejected before the request is sent. Successful renames update the active header and left rail from the returned backend thread record without reloading the transcript. + To start a new thread, click **+ New thread**. To change the responding agent, open the thread header dropdown and pick from the list of agent presets defined for this project. Each post triggers a routed invocation: the dashboard records the request, dispatches it to the chosen provider via the worker assignment service (routed through the `dashboard_reply` invocation type), and streams the reply back into the thread. diff --git a/docs/architecture/chat-thread-runtime.md b/docs/architecture/chat-thread-runtime.md index bd90a43844..9186e7f9d7 100644 --- a/docs/architecture/chat-thread-runtime.md +++ b/docs/architecture/chat-thread-runtime.md @@ -32,6 +32,8 @@ Automatic worker pickup occurs seamlessly. If a project has an inherited worker UI interactions like invocation stats visibility (`code-ux:invocation-stats-visible`) and optimistic feedback states (e.g., `ActionFeedbackRegion` for refresh/errors and `role='status'` for working bubbles) are explicitly managed and durable across navigation to preserve a responsive, readable chat experience. The Threads/Invocations mode switch is a keyboard-accessible tablist with arrow/Home/End navigation, visible selected-state cues, count/status copy, and static reduced-motion indicators so selection does not depend on animated movement. +Thread title edits use the same durable thread record contract as routing updates. The dashboard sends `PATCH /api/conversations/threads/:threadId` with `{ title }`, blocks empty titles before dispatch, and replaces the returned `ChatThread` in the active thread snapshot and cached rail list so title-only changes are reflected without forcing a transcript reload. + Route resolution now follows this precedence on each posted message: - honor an explicit thread-level worker route when the targeted worker endpoint is still live - otherwise honor an explicit thread-level virtual provider route using the stored provider plus current `dashboard_reply` provider settings for model, API key, and thinking mode diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 75e6cfe629..bfe2df4a95 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -481,6 +481,7 @@ Legacy runtime: - `Settings > Sprint & Git` now includes the QA controls immediately below `Merge Gates & Autofix`, with per-trigger multi-select agent assignment across all project agents, QA-labeled presets floated to the top, the same project-scope behavior preserved for local QA edits, and the persisted settings path still anchored at `agents.qualityAssurance`. Leaving a trigger with no custom agents selected clearly uses the built-in QA fallback without saving placeholder preset ids. - Chat page is DB-backed and stores project conversation threads/messages in sqlite - New dashboard chat threads derive an 8-word-or-less title from the first visible user message and mirror that title to `.code-ux/conversations//session-title.md`; hidden/internal messages do not drive user-facing titles. +- Chat thread titles can be renamed inline from the active thread header. The header editor supports pointer and keyboard use, rejects empty titles before sending, uses `PATCH /api/conversations/threads/:threadId` with `{ title }`, and updates both the active header and thread rail from the returned thread record without replacing the visible transcript. - Chat page now provides a `Threads / Invocations` toggle to switch between human conversation threads and read-only execution invocations. - Chat page UI is redesigned with animated identities, structured widgets for rich messages, and automatic worker pickup derived from active project routing. - Chat page logs invocation activity explicitly in the background, providing observable execution artifacts directly in the chat view. diff --git a/docs/dashboard/design-system-chat.md b/docs/dashboard/design-system-chat.md index 75fb053c50..aa660dc737 100644 --- a/docs/dashboard/design-system-chat.md +++ b/docs/dashboard/design-system-chat.md @@ -28,6 +28,8 @@ The chat and invocation design system for the Code UX dashboard defines the layo - Seamless mode switching between standard "Threads" (user-facing chat) and "Invocations" (runtime debugging transcript). - Consistent padding and gap spacing to prevent layout jitter during these transitions. - The invocation rail renders the first 40 newest invocations by default, then lazy-loads additional pages as the user scrolls near the bottom of the rail. The rail header and mode tab use the backend `totalCount`, not the number of loaded rows, so long-running projects show the real invocation total while keeping initial load lightweight. +- The active thread header includes an inline title editor with explicit save and cancel controls. Enter saves, Escape cancels, empty titles are rejected locally, and pending/error states stay inside the header so the conversation transcript remains stable while the backend returns the updated thread record. +- Thread rail title rows clamp to two stable lines and wrap long words so renamed threads stay readable without resizing the rail unpredictably. ## Accessibility - **Tab Navigation**: The mode switcher is a `role="tablist"` with unique `id`s for `role="tab"` elements, matching `aria-controls` to the underlying `role="tabpanel"` and `aria-labelledby` back to the tab. Roving `tabIndex` and arrow-key navigation are supported. diff --git a/tests/dashboard/v2/chat-thread-header.test.tsx b/tests/dashboard/v2/chat-thread-header.test.tsx index 761715092b..a8b1a9c702 100644 --- a/tests/dashboard/v2/chat-thread-header.test.tsx +++ b/tests/dashboard/v2/chat-thread-header.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom /** @jsx h */ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { h } from "preact"; -import { render, screen, fireEvent } from "@testing-library/preact"; +import { cleanup, render, screen, fireEvent } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import * as matchers from '@testing-library/jest-dom/matchers'; import { ChatThreadHeader } from "../../../dashboard/src/v2/components/chat/ChatThreadHeader.js"; @@ -11,6 +11,11 @@ import { buildMockChatThread } from "../factories/chat-fixture-factory.js"; expect.extend(matchers); describe("ChatThreadHeader", () => { + afterEach(() => { + cleanup(); + }); + + const noopRename = vi.fn(() => Promise.resolve()); const baseThread = buildMockChatThread({ id: "t1", projectId: "p1", @@ -32,6 +37,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -47,6 +53,7 @@ describe("ChatThreadHeader", () => { thread={thread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -61,6 +68,7 @@ describe("ChatThreadHeader", () => { thread={threadActive} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -73,6 +81,7 @@ describe("ChatThreadHeader", () => { thread={threadReplay} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -87,6 +96,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={true} isCancelling={false} /> @@ -104,6 +114,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={onCompact} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -129,6 +140,7 @@ describe("ChatThreadHeader", () => { thread={thread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -144,6 +156,7 @@ describe("ChatThreadHeader", () => { thread={thread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -160,6 +173,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={() => {}} onCancelActiveTurn={onCancelActiveTurn} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -172,6 +186,7 @@ describe("ChatThreadHeader", () => { thread={{ ...baseThread, pendingMessageCount: 1 }} onCompact={() => {}} onCancelActiveTurn={onCancelActiveTurn} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -187,6 +202,7 @@ describe("ChatThreadHeader", () => { thread={{ ...baseThread, pendingMessageCount: 1 }} onCompact={() => {}} onCancelActiveTurn={onCancelActiveTurn} + onRename={noopRename} isCompacting={false} isCancelling={true} /> @@ -196,4 +212,74 @@ describe("ChatThreadHeader", () => { expect(cancellingButton).toBeDisabled(); expect(cancellingButton).toHaveAttribute("aria-busy", "true"); }); + + it("saves a renamed title from the keyboard", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(() => Promise.resolve()); + render( + {}} + onCancelActiveTurn={() => {}} + onRename={onRename} + isCompacting={false} + isCancelling={false} + /> + ); + + await user.click(screen.getByRole("button", { name: "Rename Test Thread" })); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(input); + await user.type(input, "Renamed Thread{Enter}"); + + expect(onRename).toHaveBeenCalledWith("Renamed Thread"); + }); + + it("rejects empty renamed titles before calling the API", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(() => Promise.resolve()); + render( + {}} + onCancelActiveTurn={() => {}} + onRename={onRename} + isCompacting={false} + isCancelling={false} + /> + ); + + await user.click(screen.getByRole("button", { name: "Rename Test Thread" })); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(input); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent("Thread title is required."); + }); + + it("cancels rename edits with Escape", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(() => Promise.resolve()); + render( + {}} + onCancelActiveTurn={() => {}} + onRename={onRename} + isCompacting={false} + isCancelling={false} + /> + ); + + await user.click(screen.getByRole("button", { name: "Rename Test Thread" })); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(input); + await user.type(input, "Draft"); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onRename).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox", { name: "Thread title" })).not.toBeInTheDocument(); + expect(screen.getByText("Test Thread")).toBeInTheDocument(); + }); }); From 70d5cbda614bc5283b06aee3bc15147c4488bf4a Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 01:43:00 +0000 Subject: [PATCH 5/6] feat(task T05): implement via codex --- docs-web/user/dashboard/chat.md | 4 ++-- docs/dashboard/accessibility-quality-audit.md | 5 +++-- docs/dashboard/dashboard-guide.md | 12 +++++++----- docs/dashboard/design-system-chat.md | 4 +++- docs/dashboard/design-system-shell-navigation.md | 4 ++-- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index 55cff1399f..fb948575fa 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -17,9 +17,9 @@ A *thread* is a persistent conversation with an agent. Each thread has: - A **routing config** — which agent preset and provider answers when you post a message. - A **session** — the underlying provider session. Sessions can be **compacted** (summarised) to fit within context limits. -New dashboard chat threads derive an 8-word-or-less title from the first visible user message. Code UX stores the title in sqlite and mirrors it to `.code-ux/conversations//session-title.md` inside the project checkout; manual title edits update both places. +New dashboard chat threads derive an 8-word-or-less title from the first visible user message. Code UX stores the title in sqlite and mirrors it to `.code-ux/conversations//session-title.md` inside the project checkout; manual title edits update both places. Prompt preparation also includes a title-refresh instruction every 20 provider invocations so long-running conversations can update their title from current context. -To rename a thread, use the edit control beside the active thread title. The inline editor supports pointer and keyboard workflows: Enter saves, Escape cancels, explicit save/cancel buttons are available, and empty titles are rejected before the request is sent. Successful renames update the active header and left rail from the returned backend thread record without reloading the transcript. +To rename a thread, use the edit control beside the active thread title. The inline editor supports pointer and keyboard workflows: Enter saves, Escape cancels, explicit save/cancel buttons are available, and empty titles are rejected before the request is sent. Successful renames update the active header and left rail from the returned backend thread record without reloading the transcript. Long titles wrap or truncate inside the header, while rail titles clamp to two readable lines. To start a new thread, click **+ New thread**. To change the responding agent, open the thread header dropdown and pick from the list of agent presets defined for this project. diff --git a/docs/dashboard/accessibility-quality-audit.md b/docs/dashboard/accessibility-quality-audit.md index a7403ab631..a45fbedd9f 100644 --- a/docs/dashboard/accessibility-quality-audit.md +++ b/docs/dashboard/accessibility-quality-audit.md @@ -19,6 +19,7 @@ The contracts below are implemented across `dashboard/src/v2/components/ui/*`, ` ## Keyboard And Focus - Menus, listboxes, tabs, and browser/task/sprint selectors must support `Enter`, `Space`, arrow keys, `Home`, `End`, and `Escape` according to their role. `Escape` closes the surface and restores focus to the trigger. +- Inline chat thread rename controls must be keyboard complete: focus enters the editable title field from the visible edit control, Enter saves, Escape cancels, save/cancel buttons remain reachable, empty titles are rejected before submit, and focus returns to a sensible title/header control after completion. - Dialogs, alert dialogs, popovers that act as dialogs, Add Project/Add Task modals, destructive confirmations, and unsaved-change prompts must trap focus while open and restore focus after close. If the trigger disappears, focus falls back to a sensible page landmark. - Route changes that originate from dashboard controls should not strand focus in removed DOM. When a control navigates to another route or switches a major workbench surface, the destination page must expose a named landmark that can receive programmatic or natural focus. - Destructive actions must require an explicit confirmation pattern. Hold-to-confirm and danger dialogs must expose the destructive target in the accessible name or description and keep progress descriptions stable rather than repeatedly announcing every animation frame. @@ -44,8 +45,8 @@ The contracts below are implemented across `dashboard/src/v2/components/ui/*`, ` ## Responsive And Motion -- Narrow viewports must not gain page-level horizontal scroll. Rails, tables, logs, JSON previews, file paths, branch names, provider/model names, and preview controls must wrap or scroll inside their own bounded component. -- Long labels must use `min-w-0` with `break-words`, `break-all`, or bounded internal overflow. Avoid truncating operational values when operators need the exact provider, model, branch, path, workflow, or connection id. +- Narrow viewports must not gain page-level horizontal scroll. Rails, tables, logs, JSON previews, file paths, branch names, provider/model names, chat thread titles, and preview controls must wrap or scroll inside their own bounded component. +- Long labels must use `min-w-0` with `break-words`, `break-all`, or bounded internal overflow. Chat thread titles may wrap or clamp within the header and rail, but avoid truncating operational values when operators need the exact provider, model, branch, path, workflow, or connection id. - Warm Void surfaces should stay visually restrained: use neutral glass surfaces for structure, Signal Jade for primary focus/selection/accent, and Ember/status tones only for warnings, errors, danger, and destructive actions. - Motion must use the shared motion tokens and reduced-motion hooks/classes. Reduced motion removes or snaps movement while preserving static state cues such as rings, halos, badges, progress values, highlighted active tabs, and visible chart summaries. - Mobile and text-zoom checks must include shell selectors, Settings forms, Browser rails, Tasks cards, Stats tables, and live telemetry panels at narrow widths with long provider/model names. diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 49e7c64e82..c5e69c2e92 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -276,8 +276,8 @@ Legacy runtime: - V2 pages use the shared `PageContainer` atomic component for page-level layout. It renders fullscreen (`max-w-none`, no fixed cap) with a consistent horizontal/vertical padding rhythm, and is the single source of truth for page container width across overview, project, sprint, task, live, memory, knowledge, stats, settings, agents, chat, and browser routes. - V2 pages render their intro/heading via the shared `PageHeader` atomic component (`components/layout/PageHeader.tsx`): an optional icon + uppercase eyebrow, a unified `text-2xl md:text-3xl` title, an optional subtitle, and optional actions. Header titles and subtitles use balanced wrapping, and action clusters stack/wrap below the heading until the `lg` breakpoint so mobile and tablet layouts do not squeeze controls beside long titles. Keep all non-H1 headings visually lighter than the route title with explicit Tailwind classes, generally `text-xl`/`text-2xl` with `font-semibold` for section headings and `text-base`/`text-lg` with `font-semibold` for card titles. - Light mode resolves the shared `signal-*` utilities to a stable blue accent for active, selected, focus, and primary controls; dark mode keeps the existing jade signal. Use the semantic signal utilities or CSS variables instead of hardcoded green values in new dashboard UI. -- Top-nav project selector persists the active project in sqlite -- Top-nav sprint selector persists the active sprint for the selected project. The header dropdown lists only real sprints; if persisted scope is null, `All Sprints` remains a fallback trigger label rather than a selectable header row. +- Top-nav project selector persists the active project in sqlite and uses a bounded scrollable listbox so long project lists stay inside the header overlay. +- Top-nav sprint selector persists the active sprint for the selected project and uses the same bounded scrollable listbox pattern. The header dropdown lists only real sprints; if persisted scope is null, `All Sprints` remains a fallback trigger label rather than a selectable header row. - Top-nav search sits in the left header cluster beside the brand and lazy-loads project tasks only after the search overlay opens; the active task counter uses the same compact height as the project, sprint, and worker selectors - Global Search preserves previous results during its token-timed debounce to avoid layout shift, only polls for container previews when opened, and keeps arrow-key/Enter/Escape navigation wired through `aria-activedescendant` while focus remains on the combobox. The trigger, overlay entrance/exit, row reveal, active-row movement, and control feedback all resolve through the shared `enterExit`, `listReveal`, `selectionMovement`, and `controlFeedback` motion contracts; reduced-motion users get instant state changes with static cues such as focus rings, selected borders, disabled badges, count chips, `aria-busy`, and live copy. Stale result refreshes keep current rows visible with `aria-busy`, a single polite refresh announcement, and a persistent updating badge, while a newly committed query with no matches shows a true empty state instead of a loading placeholder. Keyboard movement scrolls only the overlay results container so the page behind the search does not jump. Unavailable rows remain inspectable with a visible reason referenced by `aria-describedby`, are marked `aria-disabled`, are skipped by pointer and keyboard activation when another row can open, and stay non-navigating on Enter when every result is unavailable. Sprint results use the selected project's configured sprint key prefix, so searches for project keys such as `CODUX-32` match the same sprint key shown in the row. Selecting a sprint opens the Sprints page with `?sprintKey=` so the ledger filter is seeded from the explicit route payload rather than from visible row text. - Shared dropdown menus enhance nested menu items inside layout wrappers, so keyboard navigation and item entrance animation remain consistent when menu content is grouped. @@ -480,8 +480,10 @@ Legacy runtime: - The first built-in role is `Planning agent`, which is editable under Agents like any other DB-backed agent - `Settings > Sprint & Git` now includes the QA controls immediately below `Merge Gates & Autofix`, with per-trigger multi-select agent assignment across all project agents, QA-labeled presets floated to the top, the same project-scope behavior preserved for local QA edits, and the persisted settings path still anchored at `agents.qualityAssurance`. Leaving a trigger with no custom agents selected clearly uses the built-in QA fallback without saving placeholder preset ids. - Chat page is DB-backed and stores project conversation threads/messages in sqlite -- New dashboard chat threads derive an 8-word-or-less title from the first visible user message and mirror that title to `.code-ux/conversations//session-title.md`; hidden/internal messages do not drive user-facing titles. -- Chat thread titles can be renamed inline from the active thread header. The header editor supports pointer and keyboard use, rejects empty titles before sending, uses `PATCH /api/conversations/threads/:threadId` with `{ title }`, and updates both the active header and thread rail from the returned thread record without replacing the visible transcript. +- New dashboard chat threads derive an 8-word-or-less title from the first visible user message, persist it with the thread, and mirror it to `.code-ux/conversations//session-title.md`; hidden/internal messages do not drive user-facing titles. +- Prompt preparation includes a title-refresh instruction every 20 provider invocations so long-running conversations can update their title from current context without replacing the visible transcript. +- Chat thread titles can be renamed inline from the active thread header. The header editor supports pointer and keyboard use, saves on Enter, cancels on Escape, rejects empty titles before sending, uses `PATCH /api/conversations/threads/:threadId` with `{ title }`, and updates both the active header and thread rail from the returned thread record without replacing the visible transcript. +- Active chat titles wrap or truncate inside the bounded header area, while thread rail titles clamp to two stable lines with long-word wrapping so manual renames remain readable without causing rail layout churn. - Chat page now provides a `Threads / Invocations` toggle to switch between human conversation threads and read-only execution invocations. - Chat page UI is redesigned with animated identities, structured widgets for rich messages, and automatic worker pickup derived from active project routing. - Chat page logs invocation activity explicitly in the background, providing observable execution artifacts directly in the chat view. @@ -873,7 +875,7 @@ This dashboard enforces accessibility best practices to ensure an inclusive expe - **Landmarks & Skip Links**: The dashboard shell provides exactly one `main` landmark with `id="main-content"`. A visually hidden skip link (which becomes visible on focus) allows keyboard and screen reader users to bypass the primary navigation and jump directly to the main content area. Nested components like `PageContainer` should use a `div` tag (`as="div"`) instead of `main` to prevent duplicate landmarks. - **Route Regions**: `TopNav.tsx`, `SettingsPage.tsx`, `TasksPage.tsx`, `BrowserPage.tsx`, live telemetry components, and Stats components should not introduce anonymous command surfaces when a heading, `aria-label`, or `aria-labelledby` can name the region. - **Dialogs & Modals**: Implemented using proper ARIA roles (`role="dialog"` or `role="alertdialog"`). They must have explicit accessible names via `ariaLabel`, `ariaLabelledBy`, or a visible title id; generic fallback names are not enough when the surface has a visible title. They manage focus by trapping it within the overlay, defaulting initial focus appropriately, and restoring it to the trigger upon closing. Exit animations use `pointer-events-none` to ensure hidden elements cannot be reached by Tab navigation while closing. If a dialog has no focusable elements, the container itself uses `tabIndex={-1}` and an outline-removal class for programmatic focus. -- **Menus, Selectors & Tabs**: Use explicit ARIA roles such as `menu`, `menuitem`, `listbox`, `option`, `tablist`, `tab`, and `tabpanel` according to the interaction. Top-nav project/sprint selectors, task sprint scope, Browser rails, file/change selectors, and Stats ledgers support arrows, `Home`, `End`, `Enter`, `Space`, and `Escape`; closing restores focus to the trigger. +- **Menus, Selectors & Tabs**: Use explicit ARIA roles such as `menu`, `menuitem`, `listbox`, `option`, `tablist`, `tab`, and `tabpanel` according to the interaction. Top-nav project/sprint selectors, task sprint scope, Browser rails, file/change selectors, and Stats ledgers support arrows, `Home`, `End`, `Enter`, `Space`, and `Escape`; closing restores focus to the trigger. Header project and sprint selectors keep their option lists bounded and internally scrollable, and the header sprint list contains only real sprints. - **Forms**: All inputs must have associated labels (`