mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #71672 from NousResearch/bb/tab-session-titles
fix(desktop): name a Cmd+T session from its first message
This commit is contained in:
commit
10160a1808
2 changed files with 145 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
82
apps/desktop/src/app/chat/session-tile-row.test.ts
Normal file
82
apps/desktop/src/app/chat/session-tile-row.test.ts
Normal file
|
|
@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue