mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Add Assistant session rename
Add persisted session rename support across the chat store, API contract, and Assistant session picker.
This commit is contained in:
parent
d1046132bb
commit
5503e15d23
25 changed files with 646 additions and 101 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: ""},
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue