diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index f8c2c86a67..1b01516f23 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -20,7 +20,7 @@ import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger' import { type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' +import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { cn } from '@/lib/utils' @@ -116,7 +116,7 @@ function ChatHeader({ const activeStoredSession = (selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null - const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session' + const title = activeStoredSession ? sessionTitle(activeStoredSession) : NEW_SESSION_TITLE // Which agent/persona owns this chat — glanceable in the header once a // second profile exists, so the open session's ownership is never ambiguous diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 03c7e5bc3c..05800c110b 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -36,6 +36,10 @@ export interface PaneMirror { * self-subscribing component (e.g. a session's status dot) so the strip needn't * re-sync on status/color change — only `title` drives re-registration. */ tabLead?: (key: string) => ReactNode + /** Custom label NODE for the tile's tab, self-subscribing for the same reason + * as `tabLead` — a name that moves faster than re-registration (see + * PaneChrome.tabTitle). Falls back to `title`. */ + tabTitle?: (key: string) => ReactNode /** Glyph buttons the tile contributes to the strip, after the last tab (where * "+" sits), while it is the ACTIVE pane — e.g. a preview's console / * DevTools toggles. DATA, not markup: the strip's `PaneStripGlyph` owns the @@ -82,6 +86,7 @@ export function paneMirror(cfg: PaneMirror): () => void { title, data: { tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined, + tabTitle: cfg.tabTitle ? () => cfg.tabTitle!(key) : undefined, stripTools: cfg.stripTools ? () => cfg.stripTools!(key) : undefined, dock: { before: cfg.before?.(tile), diff --git a/apps/desktop/src/app/chat/session-draft-title.tsx b/apps/desktop/src/app/chat/session-draft-title.tsx new file mode 100644 index 0000000000..e4e9a665c2 --- /dev/null +++ b/apps/desktop/src/app/chat/session-draft-title.tsx @@ -0,0 +1,25 @@ +import { NEW_SESSION_TITLE } from '@/lib/chat-runtime' +import { useStoreSelector } from '@/lib/use-session-slice' +import { $draftTitles, draftTitleIn } from '@/store/composer' + +export interface SessionDraftTitleProps { + /** The draft's composer key — a tile's stored session id, or null for the + * new chat that has no session yet. */ + scope: null | string +} + +/** + * A DRAFT'S NAME — what an unsent session is called until it has a real one. + * + * The tab of a session that has never been sent renders this instead of its + * registered title, because the name moves with the composer: every debounced + * stash republishes it. Re-registering the contribution at that rate would + * re-render the whole panes area, so the label subscribes for itself and its + * own key only. + * + * Falls back to the placeholder rather than going blank, so an emptied composer + * reads the same as one never typed into. + */ +export function SessionDraftTitle({ scope }: SessionDraftTitleProps) { + return useStoreSelector($draftTitles, titles => draftTitleIn(titles, scope)) || NEW_SESSION_TITLE +} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 9c30a632f9..e362a64d35 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -33,8 +33,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { transcribeAudio } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { sessionTitle } from '@/lib/chat-runtime' -import { createComposerAttachmentScope } from '@/store/composer' +import { NEW_SESSION_TITLE, sessionTitle } from '@/lib/chat-runtime' +import { createComposerAttachmentScope, draftTitleFor } from '@/store/composer' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree } from '@/store/projects' @@ -61,6 +61,7 @@ import type { SessionDragPayload } from './composer/inline-refs' import { type ComposerScope, ComposerScopeProvider } from './composer/scope' import { useComposerActions } from './hooks/use-composer-actions' import { paneMirror } from './pane-mirror' +import { SessionDraftTitle } from './session-draft-title' import { startSessionDrag } from './session-drag' import { SessionStatusDot } from './session-status-dot' import { useSessionTileActions } from './session-tile-actions' @@ -374,19 +375,24 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined ) } +/** The tab's REGISTERED name. Deliberately the bare placeholder for a draft + * rather than its live composer title (`tabTitle` renders that): re-registering + * per keystroke would re-render the strip, and holding the draft's text here + * would let the registered name already match the row that lands on send — + * skipping the re-register that hands the tab back to this string. */ function tileTitle(storedSessionId: string): string { const stored = tileStoredRow(storedSessionId) - // A tab-strip "+" tab is unlisted until its first turn persists, so it isn't - // in $sessions yet — label it "New session" rather than a bare "Session". - return stored ? sessionTitle(stored) : 'New session' + return stored ? sessionTitle(stored) : NEW_SESSION_TITLE } -/** The `@session` link payload for a tile tab drag — id + owning profile + title. */ +/** The `@session` link payload for a tile tab drag — id + owning profile + title. + * Resolved at drag time, so an unsent tab drags under its draft name. */ function tileDragPayload(storedSessionId: string): SessionDragPayload { const stored = tileStoredRow(storedSessionId) + const title = stored ? sessionTitle(stored) : draftTitleFor(storedSessionId) || NEW_SESSION_TITLE - return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) } + return { id: storedSessionId, profile: stored?.profile ?? '', title } } // --------------------------------------------------------------------------- @@ -588,6 +594,10 @@ export const watchSessionTiles = paneMirror({ tabLead: storedSessionId => ( ), + // Until the first turn lists a row there is no title to register, so the tab + // takes its name from the composer instead — live, without re-registering. + tabTitle: storedSessionId => + tileStoredRow(storedSessionId) ? null : , render: storedSessionId => , tabWrap: (storedSessionId, tab) => ( { registry.register({ id: 'workspace', area: 'panes', - title: stored ? storedSessionTitle(stored) : 'New session', + // The placeholder, not the draft's live name — `tabTitle` below renders + // that. Keeping it here would re-register the pane on every keystroke. + title: stored ? storedSessionTitle(stored) : NEW_SESSION_TITLE, data: { // The tab's status dot — the SAME primitive the sidebar row and session - // tiles render, so the main tab never disagrees with its sidebar row. No - // dot on a fresh draft (no session yet). - tabLead: selected ? () => : undefined, + // tiles render, so the main tab never disagrees with its sidebar row. A + // fresh draft has no session to key by, which IS its status: the dot + // resolves to `draft` and marks the tab rather than leaving a hole. + tabLead: () => , + // A draft's name lives in its composer, not in any session row, so the + // label subscribes to it directly — typing renames the tab without + // re-registering the pane. + tabTitle: stored ? undefined : () => , // Pages aren't tab-able: the main zone's bar stands down while one shows. headerVeto: $workspaceIsPage.get(), placement: 'main', diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts index 46b49ec514..f70b8222ec 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -81,6 +81,12 @@ interface PaneChrome extends PaneSizing { * the tab and the sidebar row render status/color from the ONE primitive * (self-subscribing — it updates without the strip re-registering). */ tabLead?: () => React.ReactNode + /** This pane's TAB LABEL, when it changes faster than the contribution + * should. A session pane whose draft is being typed renames on every + * debounce beat; re-registering `title` that often would re-render the + * whole panes area, so the label subscribes for itself instead. Absent, or + * returning nothing, falls back to `title`. */ + tabTitle?: () => React.ReactNode /** Glyph buttons this pane contributes to the strip, rendered after the last * tab (where "+" sits) while the pane is ACTIVE — controls that act on the * pane, not on any one tab: a preview's console / DevTools toggles. DATA, not diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index e356aacac0..4cebc44bb2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -312,6 +312,10 @@ export function TreeGroup({ // leaving the tree. const closeableTab = (paneId: string) => !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId) + // A pane's own live label when it has one, else its registered string. + const tabLabel = (paneId: string) => + paneChrome(paneFor(paneId)).tabTitle?.() ?? paneFor(paneId)?.title ?? paneId + // Collapse/restore a tool panel (or plain minimize elsewhere) — the header // chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful. const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId)) @@ -378,7 +382,6 @@ export function TreeGroup({ > {shown.map(paneId => { const closeable = closeableTab(paneId) - const title = paneFor(paneId)?.title ?? paneId return ( - {title} + {tabLabel(paneId)} ) })} @@ -547,7 +550,7 @@ export function TreeGroup({ {chrome.tabLead ? ( {chrome.tabLead()} ) : null} - {title} + {tabLabel(paneId)} ) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index c581daaea9..1aa826eec3 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -66,6 +66,10 @@ export function sessionTitle(session: SessionInfo): string { return session.title?.trim() || session.preview?.trim() || 'Untitled session' } +/** What a session is called before it has been sent — and before its composer + * has been typed into, which is the only thing that can name it earlier. */ +export const NEW_SESSION_TITLE = 'New session' + export function coerceGatewayText(value: unknown): string { if (typeof value === 'string') { return value diff --git a/apps/desktop/src/lib/draft-title.ts b/apps/desktop/src/lib/draft-title.ts new file mode 100644 index 0000000000..2c980b1472 --- /dev/null +++ b/apps/desktop/src/lib/draft-title.ts @@ -0,0 +1,40 @@ +import { SLASH_COMMAND_RE } from './chat-runtime' + +/** Matches `agent/title_generator.py`'s MAX_DERIVED_TITLE_CHARS, so a draft + * doesn't visibly reflow the moment the backend's derived title replaces it. */ +const MAX_DRAFT_TITLE_CHARS = 48 + +/** + * Name a draft after what the user has typed into it. + * + * The client-side twin of the backend's `derive_title`: first meaningful line, + * whitespace collapsed, cut on a word boundary. It runs before any session + * exists, so it can't reach the real titler — a draft has no persisted row and + * no opening message yet, which is exactly what `apply_instant_title` needs. + * + * Empty when there's nothing worth naming, so the caller keeps "New session" + * rather than showing a title that says less than the placeholder. + */ +export function deriveDraftTitle(text: string): string { + const line = text.split('\n').find(candidate => candidate.trim())?.trim() ?? '' + + if (!line) { + return '' + } + + // A bare `/skin` names the draft after the command rather than the work, the + // failure the backend titler summarizes away. Title from the argument instead; + // with no argument there is no intent yet, so the placeholder stands. + const body = (SLASH_COMMAND_RE.test(line) ? line.replace(/^\/\S+\s*/, '') : line).split(/\s+/).join(' ') + + if (body.length <= MAX_DRAFT_TITLE_CHARS) { + return body + } + + // Cut on a word boundary, unless that would throw away more than half of it. + const cut = body.slice(0, MAX_DRAFT_TITLE_CHARS) + const space = cut.lastIndexOf(' ') + const kept = space > MAX_DRAFT_TITLE_CHARS / 2 ? cut.slice(0, space) : cut + + return `${kept.replace(/[\s,.;:—-]+$/, '')}…` +} diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 6783db9637..9f2d541f9b 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -1,5 +1,6 @@ import { atom } from 'nanostores' +import { deriveDraftTitle } from '@/lib/draft-title' import { triggerHaptic } from '@/lib/haptics' export interface ComposerAttachment { @@ -151,6 +152,50 @@ function loadPersistedDraftTexts(): [string, SessionDraft][] { const draftsBySession = new Map(loadPersistedDraftTexts()) +/** + * What each unsent draft would be called, keyed the same way its text is. + * + * A draft has no session to carry a title, so the tab showing it reads this + * instead of the "New session" placeholder. Written from `stashSessionDraft`, + * the one funnel every composer's text already flows through — the debounce + * that persists a draft is the same beat that renames its tab, so typing costs + * nothing extra. Only tabs showing a draft subscribe, and each selects its own + * key, so a rename repaints one label rather than the strip. + * + * Seeded from the persisted texts: a draft left open across a restart comes + * back already named. + */ +export const $draftTitles = atom>( + Object.fromEntries( + [...draftsBySession].map(([key, draft]) => [key, deriveDraftTitle(draft.text)]).filter(([, title]) => title) + ) +) + +/** Read one draft's title out of the map — for a `useStoreSelector`, so a tab + * repaints on its OWN rename rather than on every draft's. */ +export const draftTitleIn = (titles: Record, scope: string | null | undefined): string => + titles[draftKey(scope)] ?? '' + +export const draftTitleFor = (scope: string | null | undefined): string => draftTitleIn($draftTitles.get(), scope) + +function publishDraftTitle(key: string, title: string): void { + const current = $draftTitles.get() + + if ((current[key] ?? '') === title) { + return + } + + const next = { ...current } + + if (title) { + next[key] = title + } else { + delete next[key] + } + + $draftTitles.set(next) +} + /** * Re-read the persisted drafts written by ANOTHER window into this one's map. * @@ -169,12 +214,14 @@ export function reloadPersistedDrafts(): void { for (const [key, draft] of incoming) { const local = draftsBySession.get(key) draftsBySession.set(key, local?.attachments.length ? { ...local, text: draft.text } : draft) + publishDraftTitle(key, deriveDraftTitle(draft.text)) } // A key that vanished from storage was cleared (sent) in the other window. for (const key of [...draftsBySession.keys()]) { if (!incoming.has(key)) { draftsBySession.delete(key) + publishDraftTitle(key, '') } } } @@ -257,6 +304,7 @@ export function stashSessionDraft(scope: string | null | undefined, text: string } persistDraftTexts() + publishDraftTitle(key, deriveDraftTitle(text)) } export function takeSessionDraft(scope: string | null | undefined): SessionDraft {