diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index ac683a291a7a..0a6db9733508 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -24,10 +24,12 @@ import { resetSessionBackground } from '@/store/composer-status' import { notifyError } from '@/store/notifications' import { clearPreviewArtifacts } from '@/store/preview-status' import { clearAllPrompts } from '@/store/prompts' -import { $connection } from '@/store/session' +import { $connection, $sessions, sessionMatchesStoredId } from '@/store/session' import { $sessionStates, sessionTileDelegate } from '@/store/session-states' +import { broadcastSessionsChanged } from '@/store/session-sync' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' +import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions' import { @@ -43,9 +45,49 @@ import { } from '../session/hooks/use-prompt-actions/rewind' import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit' import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils' +import { upsertOptimisticSession } from '../session/hooks/use-session-actions/utils' import type { ComposerScope } from './composer/scope' +/** + * List a tile's session in the sidebar/tab strip on its first send. + * + * A ⌘T tab's session is created UNLISTED (see `openNewSessionTile`), so it has + * no `$sessions` row until its first turn persists and a refresh surfaces it — + * for that whole first exchange the tab and the sidebar read "New session". + * ⌘N has no such gap: its session is created per-send and seeded with the + * user's text as the row preview. Seeding the same way here names the session + * within the first message; the server's auto-title supersedes it once the turn + * completes. + * + * No-ops on empty text and on a session that is already listed, so re-sends + * never clobber a real title with a raw message preview. + */ +export function listTileSessionRow(deps: { + cwd?: string + model?: string + preview: string + runtimeId: string + sessions: readonly SessionInfo[] + storedSessionId: string +}): boolean { + const preview = deps.preview.trim() + + if (!preview || deps.sessions.some(session => sessionMatchesStoredId(session, deps.storedSessionId))) { + return false + } + + upsertOptimisticSession( + { info: { cwd: deps.cwd, model: deps.model }, session_id: deps.runtimeId, stored_session_id: deps.storedSessionId }, + deps.storedSessionId, + null, + preview + ) + broadcastSessionsChanged() + + return true +} + interface SessionTileActionsArgs { runtimeId: string scope: ComposerScope @@ -90,6 +132,23 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], []) const readMessages = useCallback(() => readState()?.messages ?? [], [readState]) + // A ⌘T tab's session is unlisted until its first turn persists — seed the + // row from the user's first message so the tab and sidebar name it right + // away (see listTileSessionRow). + const listTileSession = useCallback((preview: string) => { + const runtimeId = runtimeIdRef.current + const state = $sessionStates.get()[runtimeId] + + listTileSessionRow({ + cwd: state?.cwd, + model: state?.model, + preview, + runtimeId, + sessions: $sessions.get(), + storedSessionId: storedIdRef.current + }) + }, []) + // Tile-side attachment staging: same upload rules as the primary submit // (skip synced/pathless, byte-upload files+images), against the tile scope. const syncAttachmentsForSubmit = useCallback( @@ -163,6 +222,8 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses const visibleText = rawText.trim() const attachments = options?.attachments ?? scope.attachments.$attachments.get() + listTileSession(visibleText) + if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { triggerHaptic('selection') await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current) @@ -172,7 +233,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses return await submitPromptText(rawText, options) }, - [scope.attachments.$attachments, submitPromptText] + [listTileSession, scope.attachments.$attachments, submitPromptText] ) const appendSystemNote = useCallback( diff --git a/apps/desktop/src/app/chat/session-tile-row.test.ts b/apps/desktop/src/app/chat/session-tile-row.test.ts new file mode 100644 index 000000000000..2c6eb0479e6a --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-row.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { listTileSessionRow } from './session-tile-actions' + +const STORED = 'stored-tab' +const RUNTIME = 'runtime-tab' + +function row(overrides: Partial = {}): SessionInfo { + return { + cwd: null, + ended_at: null, + id: STORED, + input_tokens: 0, + is_active: true, + last_active: 1, + message_count: 1, + model: null, + output_tokens: 0, + parent_session_id: null, + preview: null, + source: 'desktop', + started_at: 1, + title: null, + tool_call_count: 0, + ...overrides + } +} + +function seed(preview: string, sessions: SessionInfo[] = $sessions.get()) { + return listTileSessionRow({ + cwd: '/work/repo', + model: 'claude-opus-5', + preview, + runtimeId: RUNTIME, + sessions, + storedSessionId: STORED + }) +} + +describe('listTileSessionRow', () => { + beforeEach(() => $sessions.set([])) + afterEach(() => $sessions.set([])) + + it("lists an unlisted ⌘T tab from the user's first message", () => { + expect(seed('fix the tab titles')).toBe(true) + + const listed = $sessions.get().find(session => session.id === STORED) + + expect(listed?.preview).toBe('fix the tab titles') + expect(listed?.cwd).toBe('/work/repo') + // Title stays null so the server's auto-title is what eventually wins. + expect(listed?.title).toBeNull() + }) + + it('leaves an already-listed session alone so a real title survives re-sends', () => { + const titled = row({ title: 'Tab title fix' }) + + expect(seed('second message', [titled])).toBe(false) + expect($sessions.get()).toHaveLength(0) + }) + + it('matches a listed session across compression by lineage root', () => { + const rotated = row({ _lineage_root_id: STORED, id: 'stored-tab-compressed-2' }) + + expect(seed('after compression', [rotated])).toBe(false) + }) + + it('does not list a session on an empty or whitespace-only send', () => { + expect(seed('')).toBe(false) + expect(seed(' ')).toBe(false) + expect($sessions.get()).toHaveLength(0) + }) + + it('trims the seeded preview', () => { + seed(' padded prompt ') + + expect($sessions.get()[0]?.preview).toBe('padded prompt') + }) +})