Add Assistant session rename

Add persisted session rename support across the chat store, API contract, and Assistant session picker.
This commit is contained in:
rcourtman 2026-06-06 18:32:21 +01:00
parent d1046132bb
commit 5503e15d23
25 changed files with 646 additions and 101 deletions

View file

@ -230,6 +230,11 @@ the API-owned action audit records `executing` before dispatch and the
terminal execution result afterward. Dry-run-only plans remain planning evidence
only; lifecycle surfaces must not present them as executable, dispatch them
through agent-local command paths, or bypass the API fail-closed execution gate.
Assistant session rename through `PATCH /api/ai/sessions/{id}` follows that
same browser-safe history boundary. Lifecycle surfaces, MCP adapters, and
agents may display the updated title as human navigation metadata, but they
must not treat a renamed title as command authorization, host identity,
enrollment state, capability evidence, or install-token disclosure.
Agent lifecycle consumers of `/api/agent/events` and
`/api/agent/resource-context/{id}` must also honor the shared API command
payload boundary: API tokens with monitoring/read scope receive

View file

@ -751,6 +751,17 @@ runtime cost control, and shared AI transport surfaces.
picker must focus search, and each result must be a keyboard-addressable
option with list navigation and a named delete action instead of a
mouse-only row.
Assistant session rename is part of that same source-backed session
workflow. The referenced OpenCode source in
`packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx`
imports `DialogSessionRename` and invokes the runtime session rename action
from the session list, so Pulse must not leave rename as local picker state.
Pulse owns the equivalent through `PATCH /api/ai/sessions/{id}`,
`chat.Service.RenameSession`, and `SessionStore.Rename`; the drawer picker
may render the edit inline, but saving must persist the normalized title,
return the updated `ChatSession`, update the visible row without closing the
picker, and leave messages, handoff context, approvals, and tool evidence
unchanged.
The empty Assistant drawer may surface recent non-empty sessions as direct
resume actions using the backend session list already owned by the drawer;
it must not create a parallel recent-chat store or product-authored prompt

View file

@ -437,6 +437,15 @@ payload shape change when the portal presents compact client rows.
remediation details, and frontend clients must route searchable picker
requests through the shared `AIChatAPI.listSessions({ search, limit })`
helper rather than inventing a parallel local history endpoint.
`PATCH /api/ai/sessions/{id}` owns the Assistant session-title mutation
contract. The request body is limited to a user-visible `title`; the
handler must reject empty titles, the chat store must normalize whitespace
and bound persisted title length, and the response must be the updated
browser-safe `ChatSession` projection. Rename must not expose or mutate
stored prompts, messages, provider reasoning, model handoff context,
approvals, action state, or tool evidence. Browser clients must call the
shared `AIChatAPI.renameSession(sessionId, title)` helper so path encoding
and JSON body shape stay canonical.
Resource-context follow-up turns are different from Patrol-run rehydration:
browser-safe `handoff_metadata.kind=resource_context` must not replace a
stored rich handoff envelope with a partial metadata-only envelope, and the

View file

@ -231,6 +231,11 @@ regression protection.
before returning it to AI runtime, but it must not scan run history broadly,
hydrate resource inventories, call models, or perform persistence fan-out as
part of router setup or generic request admission.
Assistant session rename routing follows the same bounded router rule:
`PATCH /api/ai/sessions/{id}` may branch to the AI handler and relay-mobile
capability check, but router setup and auth gating must not scan session
files, load transcripts, hydrate handoff context, or inspect model/tool
history before the route-local handler owns the single title mutation.
Report branding follows the same bounded-work rule. Reporting handlers may
read provider-default env configuration and a tenant-local system-settings
record when a report is generated, but router setup and normal protected

View file

@ -121,6 +121,12 @@ surface too. The dedicated `relay:mobile:access` credential may only reach the
explicit runtime route inventory in `internal/api/relay_mobile_capability.go`,
and expanding that inventory is governed L7 work rather than a router-local
compatibility tweak.
Assistant session rename is part of that explicit mobile relay runtime
inventory: `PATCH /api/ai/sessions/{session_id}` may pass through
`relay:mobile:access` with `ai:chat` scope because it mutates only the
browser-safe session title projection. The relay inventory must not widen that
route into transcript, provider-context, approval, action-execution, or raw
tool-evidence mutation authority.
The route that mints that dedicated credential is also part of the paid Relay
boundary. `POST /api/security/tokens/relay-mobile` lives in the shared
auth/security router, but it must require the paid `relay` entitlement before

View file

@ -170,6 +170,12 @@ controls as normal product settings.
read-only Agent-context boundary. Router glue may connect providers, but
it must not become an alternate command path, raw provider-command path,
config path, environment path, or secret-bearing metadata path.
Assistant session rename routing through `PATCH /api/ai/sessions/{id}`
stays on that same auth/scope boundary: the route may accept only a
user-visible title mutation, must not expose transcript contents,
provider-bound model context, tool evidence, approvals, or action state,
and must not treat title text as trusted secret-bearing or command-bearing
input.
## Forbidden Paths

View file

@ -209,6 +209,12 @@ and recent action counts may help explain a workload or protected service, but
they must not create restore authority, backup visibility, storage-local
approval policy, or a bypass around the API-owned action and recovery
contracts.
Assistant session rename through `PATCH /api/ai/sessions/{id}` is also only
browser-safe history metadata for storage/recovery consumers. A renamed
conversation may make protected-item investigation easier to find, but title
text must not become backup coverage evidence, restore entitlement,
storage-owner identity, approval policy, or a provider-local recovery command
handoff.
Storage and recovery may also consume unified-resource TrueNAS app and VM
metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata,
as read-only workload, system, and storage-access context when explaining

View file

@ -138,11 +138,7 @@ describe('AIChatAPI', () => {
await advancePaintCheckpoint();
await streamPromise;
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
'content',
'content',
'done',
]);
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['content', 'content', 'done']);
expect(releaseLock).toHaveBeenCalledTimes(1);
});
@ -198,6 +194,27 @@ describe('AIChatAPI', () => {
await expect(AIChatAPI.listSessions()).resolves.toEqual([]);
});
it('renames sessions through the session root endpoint', async () => {
const renamed = {
id: 'session/root',
title: 'Renamed session',
created_at: '2026-06-06T10:00:00Z',
updated_at: '2026-06-06T10:05:00Z',
message_count: 2,
};
apiFetchJSONMock.mockResolvedValueOnce(renamed);
await expect(AIChatAPI.renameSession('session/root', 'Renamed session')).resolves.toEqual(
renamed,
);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Renamed session' }),
});
});
it('preserves restored Assistant tool evidence on session messages', async () => {
const messages = [
{

View file

@ -219,6 +219,15 @@ export class AIChatAPI {
});
}
// Rename a session
static async renameSession(sessionId: string, title: string): Promise<ChatSession> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}) as Promise<ChatSession>;
}
// Get messages for a session
static async getMessages(sessionId: string): Promise<ChatMessage[]> {
return apiFetchJSON(

View file

@ -88,6 +88,13 @@ const {
message_count: 0,
}),
deleteSession: vi.fn().mockResolvedValue(undefined),
renameSession: vi.fn().mockResolvedValue({
id: 'renamed-session',
title: 'Renamed session',
created_at: '2026-06-06T12:30:00Z',
updated_at: '2026-06-06T12:35:00Z',
message_count: 2,
}),
forkSession: vi.fn().mockResolvedValue({
id: 'forked-session',
title: 'Forked session',
@ -422,6 +429,13 @@ beforeEach(() => {
});
mockAIChatAPI.getStatus.mockResolvedValue({ running: true });
mockAIChatAPI.listSessions.mockResolvedValue([]);
mockAIChatAPI.renameSession.mockResolvedValue({
id: 'renamed-session',
title: 'Renamed session',
created_at: '2026-06-06T12:30:00Z',
updated_at: '2026-06-06T12:35:00Z',
message_count: 2,
});
mockAIChatAPI.forkSession.mockResolvedValue({
id: 'forked-session',
title: 'Forked session',
@ -598,7 +612,9 @@ describe('AIChat', () => {
4000,
);
fireEvent.click(screen.getByRole('button', { name: 'Close Assistant transcript copy panel' }));
fireEvent.click(
screen.getByRole('button', { name: 'Close Assistant transcript copy panel' }),
);
await waitFor(() => {
expect(screen.queryByLabelText('Assistant transcript')).not.toBeInTheDocument();
@ -1608,7 +1624,9 @@ describe('AIChat', () => {
fireEvent.click(screen.getByRole('option', { name: /Run \/sessions/ }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Pulse Assistant sessions' })).toBeInTheDocument();
expect(
screen.getByRole('dialog', { name: 'Pulse Assistant sessions' }),
).toBeInTheDocument();
});
expect(mockAIChatAPI.listSessions).toHaveBeenCalledWith({ limit: 30 });
expect(mockChat.sendMessage).not.toHaveBeenCalled();
@ -1626,7 +1644,9 @@ describe('AIChat', () => {
fireEvent.keyDown(textarea, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Assistant commands' })).not.toBeInTheDocument();
expect(
screen.queryByRole('listbox', { name: 'Assistant commands' }),
).not.toBeInTheDocument();
});
expect(textarea.value).toBe('/');
expect(mockChat.sendMessage).not.toHaveBeenCalled();
@ -1660,7 +1680,9 @@ describe('AIChat', () => {
fireEvent.keyDown(textarea, { key: 'Enter' });
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Pulse Assistant sessions' })).toBeInTheDocument();
expect(
screen.getByRole('dialog', { name: 'Pulse Assistant sessions' }),
).toBeInTheDocument();
});
expect(mockAIChatAPI.listSessions).toHaveBeenCalledWith({ limit: 30 });
expect(mockChat.sendMessage).not.toHaveBeenCalled();
@ -2535,6 +2557,65 @@ describe('AIChat', () => {
});
});
it('renames sessions inline from the picker', async () => {
mockAIChatAPI.listSessions.mockResolvedValue([
{ id: 's1', title: 'Session One', created_at: '', updated_at: '', message_count: 5 },
]);
mockAIChatAPI.renameSession.mockResolvedValue({
id: 's1',
title: 'Renamed session',
created_at: '',
updated_at: '2026-06-06T12:35:00Z',
message_count: 5,
});
renderChat();
await waitFor(() => {
expect(mockAIChatAPI.listSessions).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
await screen.findByText('Session One');
fireEvent.click(
screen.getByRole('button', { name: 'Rename Assistant session: Session One' }),
);
const titleInput = screen.getByLabelText('New title for Session One');
expect(titleInput).toHaveValue('Session One');
fireEvent.input(titleInput, { target: { value: 'Renamed session' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Assistant session title' }));
await waitFor(() => {
expect(mockAIChatAPI.renameSession).toHaveBeenCalledWith('s1', 'Renamed session');
});
expect(screen.getByText('Renamed session')).toBeInTheDocument();
expect(screen.queryByText('Session One')).not.toBeInTheDocument();
});
it('keeps empty session rename drafts local', async () => {
mockAIChatAPI.listSessions.mockResolvedValue([
{ id: 's1', title: 'Session One', created_at: '', updated_at: '', message_count: 5 },
]);
renderChat();
await waitFor(() => {
expect(mockAIChatAPI.listSessions).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
await screen.findByText('Session One');
fireEvent.click(
screen.getByRole('button', { name: 'Rename Assistant session: Session One' }),
);
const titleInput = screen.getByLabelText('New title for Session One');
fireEvent.input(titleInput, { target: { value: ' ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Assistant session title' }));
expect(mockAIChatAPI.renameSession).not.toHaveBeenCalled();
expect(mockNotificationStore.error).toHaveBeenCalledWith('Session title cannot be empty');
expect(titleInput).toBeInTheDocument();
});
it('marks the current working session in the picker', async () => {
mockChat.sessionId.mockReturnValue('s1');
mockChat.isLoading.mockReturnValue(true);

View file

@ -22,6 +22,7 @@ import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
import SettingsIcon from 'lucide-solid/icons/settings';
import XIcon from 'lucide-solid/icons/x';
import BookmarkIcon from 'lucide-solid/icons/bookmark';
import CheckIcon from 'lucide-solid/icons/check';
import LoaderCircleIcon from 'lucide-solid/icons/loader-circle';
import PlusIcon from 'lucide-solid/icons/plus';
import Trash2Icon from 'lucide-solid/icons/trash-2';
@ -70,6 +71,11 @@ import {
AI_CHAT_PROVIDER_READINESS_RETRY_LABEL,
AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF,
AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL,
AI_CHAT_RENAME_SESSION_CANCEL_LABEL,
AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE,
AI_CHAT_RENAME_SESSION_ERROR_MESSAGE,
AI_CHAT_RENAME_SESSION_LABEL,
AI_CHAT_RENAME_SESSION_SAVE_LABEL,
AI_CHAT_SESSION_MENU_TITLE,
AI_CHAT_SESSION_EMPTY_STATE,
AI_CHAT_SESSION_LOADING_STATE,
@ -152,6 +158,7 @@ const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
const AI_CHAT_PROMPT_HISTORY_LIMIT = 100;
const AI_CHAT_RECENT_MODEL_LIMIT = 8;
const AI_CHAT_PINNED_SESSION_LIMIT = 30;
const AI_CHAT_SESSION_TITLE_MAX_LENGTH = 120;
const AI_CHAT_SESSION_SEARCH_DEBOUNCE_MS = 150;
const AI_CHAT_SESSION_SEARCH_LIMIT = 30;
const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']);
@ -589,10 +596,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [sessionSearchResults, setSessionSearchResults] = createSignal<ChatSession[] | null>(null);
const [sessionSearchLoading, setSessionSearchLoading] = createSignal(false);
const [sessionSearchError, setSessionSearchError] = createSignal('');
const [renamingSessionId, setRenamingSessionId] = createSignal('');
const [sessionRenameDraft, setSessionRenameDraft] = createSignal('');
const [sessionRenameSaving, setSessionRenameSaving] = createSignal(false);
const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 });
const [forkingSession, setForkingSession] = createSignal(false);
let sessionButtonRef: HTMLButtonElement | undefined;
let sessionSearchInputRef: HTMLInputElement | undefined;
let sessionRenameInputRef: HTMLInputElement | undefined;
const sessionOptionRefs = new Map<string, HTMLButtonElement>();
let sessionSearchRequestId = 0;
const [modelSelectorOpenRequest, setModelSelectorOpenRequest] = createSignal(0);
@ -1102,16 +1113,28 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
return sessionRefreshLoading() ? AI_CHAT_SESSION_LOADING_STATE : '';
});
const resetSessionRename = () => {
setRenamingSessionId('');
setSessionRenameDraft('');
setSessionRenameSaving(false);
};
const resetSessionSearch = () => {
sessionSearchRequestId += 1;
setSessionSearchQuery('');
setSessionSearchResults(null);
setSessionSearchLoading(false);
setSessionSearchError('');
resetSessionRename();
};
const focusSessionSearch = () => {
queueMicrotask(() => sessionSearchInputRef?.focus());
};
const focusSessionRenameInput = () => {
queueMicrotask(() => {
sessionRenameInputRef?.focus();
sessionRenameInputRef?.select();
});
};
const findKnownSession = (sessionId: string) =>
sessions().find((candidate) => candidate.id === sessionId) ||
@ -1368,8 +1391,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const upsertSession = (session: ChatSession) => {
setSessions((prev) => [session, ...prev.filter((candidate) => candidate.id !== session.id)]);
setSessionSearchResults(
(prev) =>
prev && [session, ...prev.filter((candidate) => candidate.id !== session.id)],
(prev) => prev && [session, ...prev.filter((candidate) => candidate.id !== session.id)],
);
};
@ -2624,8 +2646,71 @@ export const AIChat: Component<AIChatProps> = (props) => {
.join(', ');
const getSessionPinLabel = (session: ChatSession) =>
`${isSessionPinned(session.id) ? 'Unpin' : 'Pin'} Assistant session: ${session.title || 'Untitled'}`;
const getSessionRenameLabel = (session: ChatSession) =>
`${AI_CHAT_RENAME_SESSION_LABEL}: ${session.title || 'Untitled'}`;
const getSessionDeleteLabel = (session: ChatSession) =>
`Delete Assistant session: ${session.title || 'Untitled'}`;
const isSessionRenaming = (sessionId: string) => renamingSessionId() === sessionId;
const normalizeSessionRenameTitle = (title: string) => {
const normalized = title.trim().replace(/\s+/g, ' ');
const chars = Array.from(normalized);
return chars.length > AI_CHAT_SESSION_TITLE_MAX_LENGTH
? chars.slice(0, AI_CHAT_SESSION_TITLE_MAX_LENGTH).join('')
: normalized;
};
const startRenamingSession = (session: ChatSession, event: Event) => {
event.stopPropagation();
if (sessionRenameSaving()) return;
setRenamingSessionId(session.id);
setSessionRenameDraft(session.title || '');
focusSessionRenameInput();
};
const cancelRenamingSession = (event?: Event) => {
event?.preventDefault();
event?.stopPropagation();
const sessionId = renamingSessionId();
resetSessionRename();
queueMicrotask(() => sessionOptionRefs.get(sessionId)?.focus());
};
const handleSessionRenameKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
if (event.key === 'Escape') {
cancelRenamingSession(event);
}
};
const submitSessionRename = async (session: ChatSession, event: Event) => {
event.preventDefault();
event.stopPropagation();
if (sessionRenameSaving()) return;
const title = normalizeSessionRenameTitle(sessionRenameDraft());
if (!title) {
notificationStore.error(AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE);
focusSessionRenameInput();
return;
}
if (title === normalizeSessionRenameTitle(session.title || '')) {
cancelRenamingSession();
return;
}
setSessionRenameSaving(true);
try {
const updatedSession = await AIChatAPI.renameSession(session.id, title);
upsertSession(updatedSession);
resetSessionRename();
queueMicrotask(() => sessionOptionRefs.get(updatedSession.id)?.focus());
} catch (error) {
logger.error('[AIChat] Failed to rename session:', error);
notificationStore.error(AI_CHAT_RENAME_SESSION_ERROR_MESSAGE);
setSessionRenameSaving(false);
focusSessionRenameInput();
}
};
// Load session
const handleLoadSession = async (sessionId: string) => {
@ -2940,101 +3025,175 @@ export const AIChat: Component<AIChatProps> = (props) => {
: ''
}`}
>
<button
type="button"
ref={(button) => {
sessionOptionRefs.set(session.id, button);
}}
role="option"
aria-selected={isSessionCurrent(session.id)}
aria-label={getSessionPickerOptionLabel(session)}
class="min-w-0 flex-1 text-left focus:outline-none"
onClick={() => handleLoadSession(session.id)}
onKeyDown={(event) =>
handleSessionOptionKeyDown(event, session.id)
}
>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 truncate text-sm font-medium text-base-content">
{session.title || 'Untitled'}
</div>
<Show when={isSessionWorking(session.id)}>
<span class="inline-flex shrink-0 items-center gap-1 rounded border border-blue-200 bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold text-blue-700 dark:border-blue-800 dark:bg-blue-950/60 dark:text-blue-200">
<LoaderCircleIcon class="h-3 w-3 animate-spin" />
Working
</span>
</Show>
<Show
when={
isSessionCurrent(session.id) &&
!isSessionWorking(session.id)
<Show
when={isSessionRenaming(session.id)}
fallback={
<button
type="button"
ref={(button) => {
sessionOptionRefs.set(session.id, button);
}}
role="option"
aria-selected={isSessionCurrent(session.id)}
aria-label={getSessionPickerOptionLabel(session)}
class="min-w-0 flex-1 text-left focus:outline-none"
onClick={() => handleLoadSession(session.id)}
onKeyDown={(event) =>
handleSessionOptionKeyDown(event, session.id)
}
>
<span class="shrink-0 rounded border border-blue-200 bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-blue-700 dark:border-blue-800 dark:bg-blue-950/60 dark:text-blue-200">
Current
</span>
</Show>
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span>
{formatSessionPickerMessageCount(session.message_count)}
</span>
<Show when={formatSessionPickerUpdatedAt(session)}>
{(updatedAt) => (
<>
<span aria-hidden="true">·</span>
<span>{updatedAt()}</span>
</>
)}
</Show>
</div>
<Show when={session.handoff_summary}>
{(summary) => (
<div class="mt-1 flex max-w-full flex-wrap gap-1.5">
<span class="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200">
{getSessionHandoffSourceLabel(summary())}
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 truncate text-sm font-medium text-base-content">
{session.title || 'Untitled'}
</div>
<Show when={isSessionWorking(session.id)}>
<span class="inline-flex shrink-0 items-center gap-1 rounded border border-blue-200 bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold text-blue-700 dark:border-blue-800 dark:bg-blue-950/60 dark:text-blue-200">
<LoaderCircleIcon class="h-3 w-3 animate-spin" />
Working
</span>
</Show>
<Show
when={
isSessionCurrent(session.id) &&
!isSessionWorking(session.id)
}
>
<span class="shrink-0 rounded border border-blue-200 bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-blue-700 dark:border-blue-800 dark:bg-blue-950/60 dark:text-blue-200">
Current
</span>
</Show>
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span>
{formatSessionPickerMessageCount(
session.message_count,
)}
</span>
<span class="rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[10px] text-muted">
{getSessionHandoffBadgeLabel(summary())}
</span>
<Show when={formatSessionHandoffStatus(summary())}>
{(status) => (
<span class="max-w-full truncate rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[10px] text-muted">
{status()}
</span>
<Show when={formatSessionPickerUpdatedAt(session)}>
{(updatedAt) => (
<>
<span aria-hidden="true">·</span>
<span>{updatedAt()}</span>
</>
)}
</Show>
</div>
)}
</Show>
</button>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class={`rounded p-1 transition-opacity focus:opacity-100 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 ${
isSessionPinned(session.id)
? 'opacity-100 text-blue-600 dark:text-blue-300'
: 'opacity-0 text-muted group-hover:opacity-100 group-focus-within:opacity-100'
}`}
onClick={(event) => togglePinnedSession(session.id, event)}
aria-pressed={isSessionPinned(session.id)}
aria-label={getSessionPinLabel(session)}
title={getSessionPinLabel(session)}
<Show when={session.handoff_summary}>
{(summary) => (
<div class="mt-1 flex max-w-full flex-wrap gap-1.5">
<span class="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200">
{getSessionHandoffSourceLabel(summary())}
</span>
<span class="rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[10px] text-muted">
{getSessionHandoffBadgeLabel(summary())}
</span>
<Show when={formatSessionHandoffStatus(summary())}>
{(status) => (
<span class="max-w-full truncate rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[10px] text-muted">
{status()}
</span>
)}
</Show>
</div>
)}
</Show>
</button>
}
>
<form
class="min-w-0 flex-1"
onSubmit={(event) => submitSessionRename(session, event)}
onClick={(event) => event.stopPropagation()}
>
<BookmarkIcon
class={`h-3.5 w-3.5 ${isSessionPinned(session.id) ? 'fill-current' : ''}`}
/>
</button>
<button
type="button"
class="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-red-100 hover:text-red-500 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-red-900"
onClick={(event) => handleDeleteSession(session.id, event)}
aria-label={getSessionDeleteLabel(session)}
title={getSessionDeleteLabel(session)}
>
<Trash2Icon class="h-3.5 w-3.5" />
</button>
</div>
<div class="flex min-w-0 items-center gap-1.5">
<input
ref={(input) => {
sessionRenameInputRef = input;
}}
value={sessionRenameDraft()}
onInput={(event) =>
setSessionRenameDraft(event.currentTarget.value)
}
onKeyDown={handleSessionRenameKeyDown}
maxLength={AI_CHAT_SESSION_TITLE_MAX_LENGTH}
disabled={sessionRenameSaving()}
aria-label={`New title for ${session.title || 'Untitled'}`}
class="min-w-0 flex-1 rounded border border-blue-300 bg-surface px-2 py-1 text-sm font-medium text-base-content outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:opacity-70 dark:border-blue-800"
/>
<button
type="submit"
disabled={sessionRenameSaving()}
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 disabled:cursor-wait disabled:opacity-70 dark:text-blue-200 dark:hover:bg-blue-900/60"
aria-label={AI_CHAT_RENAME_SESSION_SAVE_LABEL}
title={AI_CHAT_RENAME_SESSION_SAVE_LABEL}
>
<Show
when={sessionRenameSaving()}
fallback={
<CheckIcon class="h-3.5 w-3.5" aria-hidden="true" />
}
>
<LoaderCircleIcon
class="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
</Show>
</button>
<button
type="button"
disabled={sessionRenameSaving()}
onClick={cancelRenamingSession}
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content disabled:opacity-50"
aria-label={AI_CHAT_RENAME_SESSION_CANCEL_LABEL}
title={AI_CHAT_RENAME_SESSION_CANCEL_LABEL}
>
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</form>
</Show>
<Show when={!isSessionRenaming(session.id)}>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-blue-100 hover:text-blue-600 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-blue-900 dark:hover:text-blue-300"
onClick={(event) => startRenamingSession(session, event)}
aria-label={getSessionRenameLabel(session)}
title={getSessionRenameLabel(session)}
>
<PencilIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
class={`rounded p-1 transition-opacity focus:opacity-100 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 ${
isSessionPinned(session.id)
? 'opacity-100 text-blue-600 dark:text-blue-300'
: 'opacity-0 text-muted group-hover:opacity-100 group-focus-within:opacity-100'
}`}
onClick={(event) =>
togglePinnedSession(session.id, event)
}
aria-pressed={isSessionPinned(session.id)}
aria-label={getSessionPinLabel(session)}
title={getSessionPinLabel(session)}
>
<BookmarkIcon
class={`h-3.5 w-3.5 ${isSessionPinned(session.id) ? 'fill-current' : ''}`}
/>
</button>
<button
type="button"
class="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-red-100 hover:text-red-500 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-red-900"
onClick={(event) =>
handleDeleteSession(session.id, event)
}
aria-label={getSessionDeleteLabel(session)}
title={getSessionDeleteLabel(session)}
>
<Trash2Icon class="h-3.5 w-3.5" />
</button>
</div>
</Show>
</div>
)}
</For>

View file

@ -25,6 +25,11 @@ import {
AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF,
AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL,
AI_CHAT_QUESTION_CARD_TITLE,
AI_CHAT_RENAME_SESSION_CANCEL_LABEL,
AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE,
AI_CHAT_RENAME_SESSION_ERROR_MESSAGE,
AI_CHAT_RENAME_SESSION_LABEL,
AI_CHAT_RENAME_SESSION_SAVE_LABEL,
AI_CHAT_SESSION_EMPTY_STATE,
AI_CHAT_SESSION_LOADING_STATE,
AI_CHAT_SESSION_MENU_TITLE,
@ -55,6 +60,11 @@ describe('aiChatPresentation', () => {
expect(AI_CHAT_COPY_LAST_ANSWER_LABEL).toBe('Copy last Assistant answer');
expect(AI_CHAT_COPY_LAST_ANSWER_SUCCESS_MESSAGE).toBe('Assistant answer copied');
expect(AI_CHAT_COPY_LAST_ANSWER_ERROR_MESSAGE).toBe('Failed to copy Assistant answer');
expect(AI_CHAT_RENAME_SESSION_LABEL).toBe('Rename Assistant session');
expect(AI_CHAT_RENAME_SESSION_SAVE_LABEL).toBe('Save Assistant session title');
expect(AI_CHAT_RENAME_SESSION_CANCEL_LABEL).toBe('Cancel Assistant session rename');
expect(AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE).toBe('Session title cannot be empty');
expect(AI_CHAT_RENAME_SESSION_ERROR_MESSAGE).toBe('Failed to rename Assistant session');
expect(AI_CHAT_LAUNCHER_ARIA_LABEL).toBe('Expand Pulse Assistant');
expect(AI_CHAT_CLOSE_LABEL).toBe('Close Pulse Assistant');
expect(AI_CHAT_SESSION_MENU_TITLE).toBe('Pulse Assistant sessions');

View file

@ -16,6 +16,11 @@ export const AI_CHAT_FORK_SESSION_LOAD_ERROR_MESSAGE =
'Forked Assistant session but failed to load it';
export const AI_CHAT_FORK_SESSION_ERROR_MESSAGE = 'Failed to fork Assistant session';
export const AI_CHAT_FORK_SESSION_SUCCESS_MESSAGE = 'Assistant session forked';
export const AI_CHAT_RENAME_SESSION_LABEL = 'Rename Assistant session';
export const AI_CHAT_RENAME_SESSION_SAVE_LABEL = 'Save Assistant session title';
export const AI_CHAT_RENAME_SESSION_CANCEL_LABEL = 'Cancel Assistant session rename';
export const AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE = 'Session title cannot be empty';
export const AI_CHAT_RENAME_SESSION_ERROR_MESSAGE = 'Failed to rename Assistant session';
export const AI_CHAT_COPY_LAST_ANSWER_LABEL = 'Copy last Assistant answer';
export const AI_CHAT_COPY_LAST_ANSWER_SUCCESS_MESSAGE = 'Assistant answer copied';
export const AI_CHAT_COPY_LAST_ANSWER_ERROR_MESSAGE = 'Failed to copy Assistant answer';

View file

@ -3308,6 +3308,19 @@ func (s *Service) DeleteSession(ctx context.Context, sessionID string) error {
return sessions.Delete(sessionID)
}
// RenameSession updates the user-visible title for a persisted session.
func (s *Service) RenameSession(ctx context.Context, sessionID, title string) (*Session, error) {
s.mu.RLock()
sessions := s.sessions
s.mu.RUnlock()
if sessions == nil {
return nil, fmt.Errorf("service not started")
}
return sessions.Rename(sessionID, title)
}
// GetMessages returns messages for a session
func (s *Service) GetMessages(ctx context.Context, sessionID string) ([]Message, error) {
s.mu.RLock()

View file

@ -267,6 +267,9 @@ func TestService_SessionWrappers(t *testing.T) {
_, err := service.CreateSession(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "service not started")
_, err = service.RenameSession(ctx, "missing", "Renamed session")
assert.Error(t, err)
assert.Contains(t, err.Error(), "service not started")
// Start service
err = service.Start(ctx)
@ -303,13 +306,23 @@ func TestService_SessionWrappers(t *testing.T) {
require.NoError(t, err)
assert.Len(t, list, 1)
renamed, err := service.RenameSession(ctx, session.ID, "Renamed session")
require.NoError(t, err)
require.NotNil(t, renamed)
assert.Equal(t, "Renamed session", renamed.Title)
list, err = service.ListSessions(ctx)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, "Renamed session", list[0].Title)
// Fork Session
forked, err := service.ForkSession(ctx, session.ID)
require.NoError(t, err)
require.NotNil(t, forked)
assert.NotEqual(t, session.ID, forked.ID)
assert.Equal(t, 1, forked.MessageCount)
assert.Equal(t, "Forked session", forked.Title)
assert.Equal(t, "Fork of Renamed session", forked.Title)
forkedMessages, err := service.GetMessages(ctx, forked.ID)
require.NoError(t, err)

View file

@ -360,7 +360,10 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
return summary
}
const maxSessionIDLength = 128
const (
maxSessionIDLength = 128
maxSessionTitleRunes = 120
)
var errSessionNotFound = errors.New("session not found")
@ -548,6 +551,37 @@ func (s *SessionStore) Get(id string) (*Session, error) {
}, nil
}
// Rename updates a session title without touching messages or handoff context.
func (s *SessionStore) Rename(id, title string) (*Session, error) {
normalizedTitle := normalizeSessionTitle(title)
if normalizedTitle == "" {
return nil, fmt.Errorf("session title required")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
data.Title = normalizedTitle
data.UpdatedAt = time.Now()
if err := s.writeSession(*data); err != nil {
return nil, err
}
return &Session{
ID: data.ID,
Title: data.Title,
CreatedAt: data.CreatedAt,
UpdatedAt: data.UpdatedAt,
MessageCount: len(data.Messages),
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
}, nil
}
// Fork clones a persisted session into a new durable session. The copied
// messages intentionally preserve their per-session IDs so tool-call/result
// relationships remain intact inside the forked transcript.
@ -1179,6 +1213,15 @@ func generateTitle(content string) string {
return truncated + "..."
}
func normalizeSessionTitle(title string) string {
normalized := strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
runes := []rune(normalized)
if len(runes) <= maxSessionTitleRunes {
return normalized
}
return string(runes[:maxSessionTitleRunes])
}
// EnsureSession ensures a session exists, creating one if needed
func (s *SessionStore) EnsureSession(id string) (*Session, error) {
if id == "" {

View file

@ -59,6 +59,24 @@ func TestSessionStore(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
})
t.Run("Rename", func(t *testing.T) {
session, _ := store.Create()
renamed, err := store.Rename(session.ID, " Renamed\nsession ")
require.NoError(t, err)
assert.Equal(t, "Renamed session", renamed.Title)
retrieved, err := store.Get(session.ID)
require.NoError(t, err)
assert.Equal(t, "Renamed session", retrieved.Title)
})
t.Run("Rename rejects empty title", func(t *testing.T) {
session, _ := store.Create()
_, err := store.Rename(session.ID, " ")
assert.Error(t, err)
assert.Contains(t, err.Error(), "session title required")
})
t.Run("AddMessage and Title Generation", func(t *testing.T) {
session, _ := store.Create()
msg := Message{

View file

@ -47,6 +47,7 @@ type AIService interface {
ListSessions(ctx context.Context) ([]chat.Session, error)
CreateSession(ctx context.Context) (*chat.Session, error)
DeleteSession(ctx context.Context, sessionID string) error
RenameSession(ctx context.Context, sessionID, title string) (*chat.Session, error)
GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error)
GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error)
GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error)
@ -986,6 +987,10 @@ type ChatRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
}
type RenameSessionRequest struct {
Title string `json:"title"`
}
const (
chatRequestHandoffContextMaxBytes = 16 * 1024
chatRequestHandoffResourceLimit = 8
@ -2754,6 +2759,40 @@ func (h *AIHandler) HandleDeleteSession(w http.ResponseWriter, r *http.Request,
w.WriteHeader(http.StatusNoContent)
}
// HandleRenameSession handles PATCH /api/ai/sessions/{id}
func (h *AIHandler) HandleRenameSession(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
var req RenameSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Title) == "" {
http.Error(w, "Session title required", http.StatusBadRequest)
return
}
session, err := svc.RenameSession(ctx, sessionID, req.Title)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
// HandleMessages handles GET /api/ai/sessions/{id}/messages
func (h *AIHandler) HandleMessages(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()

View file

@ -40,6 +40,9 @@ func (s *capturingAIService) CreateSession(ctx context.Context) (*chat.Session,
return &chat.Session{ID: "test"}, nil
}
func (s *capturingAIService) DeleteSession(ctx context.Context, sessionID string) error { return nil }
func (s *capturingAIService) RenameSession(ctx context.Context, sessionID, title string) (*chat.Session, error) {
return &chat.Session{ID: sessionID, Title: title}, nil
}
func (s *capturingAIService) GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) {
return nil, nil
}

View file

@ -97,6 +97,14 @@ func (m *MockAIService) DeleteSession(ctx context.Context, sessionID string) err
return args.Error(0)
}
func (m *MockAIService) RenameSession(ctx context.Context, sessionID, title string) (*chat.Session, error) {
args := m.Called(ctx, sessionID, title)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*chat.Session), args.Error(1)
}
func (m *MockAIService) GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) {
args := m.Called(ctx, sessionID)
return args.Get(0).([]chat.Message), args.Error(1)
@ -558,6 +566,47 @@ func TestHandleDeleteSession(t *testing.T) {
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestHandleRenameSession(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
mockSvc.On("RenameSession", mock.Anything, "s1", "Renamed session").Return(&chat.Session{
ID: "s1",
Title: "Renamed session",
}, nil)
req := httptest.NewRequest("PATCH", "/api/ai/sessions/s1", strings.NewReader(`{"title":"Renamed session"}`))
w := httptest.NewRecorder()
h.HandleRenameSession(w, req, "s1")
assert.Equal(t, http.StatusOK, w.Code)
var got chat.Session
assert.NoError(t, json.NewDecoder(w.Body).Decode(&got))
assert.Equal(t, "Renamed session", got.Title)
mockSvc.AssertExpectations(t)
}
func TestHandleRenameSessionRejectsEmptyTitle(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
mockSvc.On("IsRunning").Return(true)
req := httptest.NewRequest("PATCH", "/api/ai/sessions/s1", strings.NewReader(`{"title":" "}`))
w := httptest.NewRecorder()
h.HandleRenameSession(w, req, "s1")
assert.Equal(t, http.StatusBadRequest, w.Code)
mockSvc.AssertNotCalled(t, "RenameSession", mock.Anything, mock.Anything, mock.Anything)
}
func TestHandleMessages(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)

View file

@ -34,6 +34,7 @@ const (
relayMobileRouteSessionCreate relayMobileRuntimeRouteID = "session-create"
relayMobileRouteSessionMessages relayMobileRuntimeRouteID = "session-messages"
relayMobileRouteSessionAbort relayMobileRuntimeRouteID = "session-abort"
relayMobileRouteSessionRename relayMobileRuntimeRouteID = "session-rename"
relayMobileRouteSessionDelete relayMobileRuntimeRouteID = "session-delete"
)
@ -55,6 +56,7 @@ var relayMobileRuntimeRouteOrder = []relayMobileRuntimeRouteID{
relayMobileRouteSessionCreate,
relayMobileRouteSessionMessages,
relayMobileRouteSessionAbort,
relayMobileRouteSessionRename,
relayMobileRouteSessionDelete,
}
@ -161,6 +163,12 @@ var relayMobileRuntimeRouteSpecs = map[relayMobileRuntimeRouteID]relayMobileRunt
path: "/api/ai/sessions/{session_id}/abort",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionRename: {
id: relayMobileRouteSessionRename,
method: http.MethodPatch,
path: "/api/ai/sessions/{session_id}",
requiredScope: config.ScopeAIChat,
},
relayMobileRouteSessionDelete: {
id: relayMobileRouteSessionDelete,
method: http.MethodDelete,

View file

@ -56,6 +56,7 @@ func TestRelayMobileRuntimeRouteInventory(t *testing.T) {
"POST /api/ai/sessions => ai:chat",
"GET /api/ai/sessions/{session_id}/messages => ai:chat",
"POST /api/ai/sessions/{session_id}/abort => ai:chat",
"PATCH /api/ai/sessions/{session_id} => ai:chat",
"DELETE /api/ai/sessions/{session_id} => ai:chat",
}

View file

@ -892,6 +892,11 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) {
// Handle session-level operations
switch req.Method {
case http.MethodPatch:
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionRename) {
return
}
r.aiHandler.HandleRenameSession(w, req, sessionID)
case http.MethodDelete:
if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionDelete) {
return

View file

@ -100,6 +100,29 @@ func TestRouteAISessions_DeleteSession(t *testing.T) {
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_RenameSession(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)
mockSvc.On("RenameSession", mock.Anything, "session-1", "Renamed session").Return(&chat.Session{
ID: "session-1",
Title: "Renamed session",
}, nil)
handler := &AIHandler{}
setUnexportedField(t, handler, "defaultService", mockSvc)
router := &Router{aiHandler: handler}
req := httptest.NewRequest(http.MethodPatch, "/api/ai/sessions/session-1", strings.NewReader(`{"title":"Renamed session"}`))
rec := httptest.NewRecorder()
router.routeAISessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
mockSvc.AssertExpectations(t)
}
func TestRouteAISessions_Abort(t *testing.T) {
mockSvc := &MockAIService{}
mockSvc.On("IsRunning").Return(true)

View file

@ -1543,6 +1543,7 @@ func TestRelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints(t *testing.T
{method: http.MethodGet, path: "/api/ai/sessions", body: ""},
{method: http.MethodGet, path: "/api/ai/sessions/session-1/messages", body: ""},
{method: http.MethodPost, path: "/api/ai/sessions/session-1/abort", body: `{}`},
{method: http.MethodPatch, path: "/api/ai/sessions/session-1", body: `{"title":"Renamed session"}`},
{method: http.MethodDelete, path: "/api/ai/sessions/session-1", body: ""},
}