729 lines
32 KiB
TypeScript
729 lines
32 KiB
TypeScript
import type { ConnectionState } from '@hermes/shared'
|
||
import { atom, computed } from 'nanostores'
|
||
|
||
import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
|
||
import type { ContextSuggestion } from '@/app/types'
|
||
import type { HermesConnection } from '@/global'
|
||
import type { ChatMessage } from '@/lib/chat-messages'
|
||
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
||
|
||
type Updater<T> = T | ((current: T) => T)
|
||
export type ComposerModelSource = '' | 'default' | 'manual'
|
||
|
||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||
|
||
// The composer's model/effort/fast is sticky UI state, NOT the profile default
|
||
// (that lives in Settings → Model). Persisting it in localStorage makes a pick
|
||
// follow across Cmd+N and app restarts instead of snapping back to the default.
|
||
// It's deliberately global (not per-profile): a profile switch force-reseeds to
|
||
// that profile's default, while within a profile new chats keep your last pick.
|
||
const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
|
||
const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
|
||
const COMPOSER_MODEL_SOURCE_KEY = 'hermes.desktop.composer.model-source'
|
||
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
|
||
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
|
||
|
||
// The last chat the user had open, so a relaunch lands back on it instead of an
|
||
// empty new-chat. Stored (not runtime) id — the route is keyed by stored id.
|
||
//
|
||
// Scoped per profile with an explicit namespace (`.profile.<encoded>`) and
|
||
// encodeURIComponent so a profile name carrying `/` or other reserved chars
|
||
// cannot collide or leak across keys. Legacy global (unsuffixed) keys are
|
||
// discarded on first read to prevent cross-profile bleed — ownership of the old
|
||
// global values is unknowable, and guessing the owning profile is exactly the
|
||
// cross-profile corruption this storage boundary prevents (#67709).
|
||
const LAST_SESSION_KEY = 'hermes.desktop.lastSessionId'
|
||
const LAST_ROUTE_KEY = 'hermes.desktop.lastRoute'
|
||
|
||
function profileNavigationKey(base: string, profile: string): string {
|
||
const key = profile.trim() || 'default'
|
||
|
||
return `${base}.profile.${encodeURIComponent(key)}`
|
||
}
|
||
|
||
// Discard legacy global keys once per tick. A module-level flag avoids
|
||
// redundant synchronous localStorage reads on every get/set call within
|
||
// the same synchronous block. The flag resets on cross-window `storage`
|
||
// events, which are the only way another window can recontaminate between
|
||
// ticks.
|
||
let legacyDiscardNeeded = true
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.addEventListener('storage', e => {
|
||
if (e.key === LAST_SESSION_KEY || e.key === LAST_ROUTE_KEY) {
|
||
legacyDiscardNeeded = true
|
||
}
|
||
})
|
||
}
|
||
|
||
function discardLegacyRememberedNavigation(): void {
|
||
if (!legacyDiscardNeeded) {
|
||
return
|
||
}
|
||
|
||
legacyDiscardNeeded = false
|
||
|
||
// Ownership of the old global values is unknowable. Never migrate them into
|
||
// a profile: guessing is exactly the cross-profile corruption this storage
|
||
// boundary prevents.
|
||
if (storedString(LAST_SESSION_KEY) !== null) {
|
||
persistString(LAST_SESSION_KEY, null)
|
||
}
|
||
|
||
if (storedString(LAST_ROUTE_KEY) !== null) {
|
||
persistString(LAST_ROUTE_KEY, null)
|
||
}
|
||
}
|
||
|
||
/** @internal Reset the legacy-discard flag for tests. */
|
||
export function _resetLegacyDiscardForTests(): void {
|
||
legacyDiscardNeeded = true
|
||
}
|
||
|
||
export function getRememberedSessionId(profile: string): null | string {
|
||
discardLegacyRememberedNavigation()
|
||
|
||
return storedString(profileNavigationKey(LAST_SESSION_KEY, profile))
|
||
}
|
||
|
||
export function setRememberedSessionId(id: null | string, profile: string): void {
|
||
discardLegacyRememberedNavigation()
|
||
persistString(profileNavigationKey(LAST_SESSION_KEY, profile), id)
|
||
}
|
||
|
||
export function sessionBelongsToProfile(
|
||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id' | 'profile'>[],
|
||
storedSessionId: string,
|
||
profile: string
|
||
): boolean {
|
||
const key = profile.trim() || 'default'
|
||
|
||
return sessions.some(session => {
|
||
const owner = (session.profile ?? '').trim() || 'default'
|
||
|
||
return owner === key && sessionMatchesStoredId(session, storedSessionId)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The profile a routed session belongs to, for keying the remembered id.
|
||
*
|
||
* Prefer the owning profile recorded on the session row (the cross-profile
|
||
* aggregator tags each row), so the session is remembered under ITS profile
|
||
* even while a different one is live. Falls back to the active gateway profile
|
||
* for a session not yet in the in-memory list.
|
||
*/
|
||
export function rememberedSessionProfile(
|
||
sessions: readonly SessionInfo[],
|
||
sessionId: null | string,
|
||
activeProfile: null | string
|
||
): string {
|
||
if (sessionId) {
|
||
const owner = sessions.find(session => sessionMatchesStoredId(session, sessionId))?.profile?.trim()
|
||
|
||
if (owner) {
|
||
return owner
|
||
}
|
||
}
|
||
|
||
return (activeProfile ?? '').trim() || 'default'
|
||
}
|
||
|
||
// The last non-overlay route (a page like /skills, or a session route), so a
|
||
// relaunch lands back where you were instead of a bare new-chat.
|
||
//
|
||
// Scoped per profile for the same reason the remembered session id is: a single
|
||
// global key remembered ONE route across every profile, and a session route
|
||
// carries a session id in its path. Restoring under profile B would navigate to
|
||
// a session owned by profile A — the remembered-id scoping above is bypassed
|
||
// entirely, because the route is preferred over the id on cold start
|
||
// (#67603 family). Legacy global values are discarded on first read.
|
||
|
||
export function getRememberedRoute(profile: string): null | string {
|
||
discardLegacyRememberedNavigation()
|
||
|
||
return storedString(profileNavigationKey(LAST_ROUTE_KEY, profile))
|
||
}
|
||
|
||
export function setRememberedRoute(path: null | string, profile: string): void {
|
||
discardLegacyRememberedNavigation()
|
||
persistString(profileNavigationKey(LAST_ROUTE_KEY, profile), path)
|
||
}
|
||
|
||
let configuredDefaultProjectDir = ''
|
||
|
||
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
||
if (connection?.mode !== 'remote') {
|
||
return WORKSPACE_CWD_KEY
|
||
}
|
||
|
||
const base = encodeURIComponent(connection.baseUrl || 'remote')
|
||
const profile = encodeURIComponent(connection.profile || 'default')
|
||
|
||
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
|
||
}
|
||
|
||
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
|
||
export type NewChatWorkspaceTarget = null | string | undefined
|
||
|
||
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
|
||
|
||
export async function syncConfiguredDefaultProjectDir(): Promise<string> {
|
||
const settings = window.hermesDesktop?.settings?.getDefaultProjectDir
|
||
|
||
if (!settings) {
|
||
configuredDefaultProjectDir = ''
|
||
|
||
return ''
|
||
}
|
||
|
||
const { dir } = await settings()
|
||
configuredDefaultProjectDir = dir?.trim() || ''
|
||
|
||
return configuredDefaultProjectDir
|
||
}
|
||
|
||
/** Align the renderer workspace with the main-process default (home dir when
|
||
* packaged, optional Settings override). Clears stale install-dir paths that
|
||
* PR #37586's localStorage stickiness can preserve across the #37536 fix. */
|
||
export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||
const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
|
||
|
||
if (!sanitize) {
|
||
return
|
||
}
|
||
|
||
await syncConfiguredDefaultProjectDir()
|
||
const configured = getConfiguredDefaultProjectDir()
|
||
|
||
const seedLiveCwd = (cwd: string) => {
|
||
if (cwd && !$activeSessionId.get()) {
|
||
setCurrentCwd(cwd)
|
||
}
|
||
}
|
||
|
||
const remembered = getRememberedWorkspaceCwd()
|
||
|
||
if ($connection.get()?.mode === 'remote') {
|
||
seedLiveCwd(remembered)
|
||
|
||
return
|
||
}
|
||
|
||
if (configured) {
|
||
const { cwd } = await sanitize(configured)
|
||
seedLiveCwd(cwd)
|
||
|
||
return
|
||
}
|
||
|
||
if (remembered) {
|
||
const { cwd } = await sanitize(remembered)
|
||
seedLiveCwd(cwd)
|
||
}
|
||
}
|
||
|
||
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
|
||
configuredDefaultProjectDir = dir?.trim() || ''
|
||
}
|
||
|
||
interface AppAtom<T> {
|
||
get: () => T
|
||
set: (value: T) => void
|
||
}
|
||
|
||
function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
|
||
}
|
||
|
||
/** Durable id for pinning. Auto-compression rotates a conversation's session
|
||
* id (root -> continuation tip), so pins keyed on the live id evaporate. The
|
||
* lineage root is stable across every compression, so we pin on that. */
|
||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||
session._lineage_root_id ?? session.id
|
||
|
||
/** True when a stored/lineage id resolves to this session — it matches either
|
||
* the live id or the stable lineage root (see sessionPinId). The one place the
|
||
* "same conversation across compression" test lives. */
|
||
export const sessionMatchesStoredId = (
|
||
session: Pick<SessionInfo, '_lineage_root_id' | 'id'>,
|
||
storedSessionId: string
|
||
): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||
|
||
/** True when two ids name the same conversation across compression tip rotation. */
|
||
export function idsShareLineage(
|
||
a: string,
|
||
b: string,
|
||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
|
||
): boolean {
|
||
if (a === b) {
|
||
return true
|
||
}
|
||
|
||
return sessions.some(session => sessionMatchesStoredId(session, a) && sessionMatchesStoredId(session, b))
|
||
}
|
||
|
||
/**
|
||
* Whether a composer draft/queue key should move from `fromKey` onto `toKey`.
|
||
*
|
||
* Only same-conversation rekeys are allowed (compression tip → lineage root).
|
||
* A session-switch window where the route already points at B while the store
|
||
* selection still holds A must NOT migrate — that would re-home Session A's
|
||
* queued prompts onto B and auto-drain them into the wrong chat.
|
||
*/
|
||
export function shouldMigrateComposerScope(
|
||
fromKey: string | null | undefined,
|
||
toKey: string | null | undefined,
|
||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
|
||
): boolean {
|
||
const from = fromKey?.trim()
|
||
const to = toKey?.trim()
|
||
|
||
if (!from || !to || from === to) {
|
||
return false
|
||
}
|
||
|
||
return idsShareLineage(from, to, sessions)
|
||
}
|
||
|
||
/**
|
||
* Stable composer + `/queue` scope for a selected stored session.
|
||
*
|
||
* Same durability rule as {@link sessionPinId}: prefer the lineage root so
|
||
* auto-compression tip rotation does not remount the composer onto an empty
|
||
* draft/queue key mid-keystroke. Falls back to the live id when the row is
|
||
* not in the in-memory list yet.
|
||
*/
|
||
export function resolveComposerSessionKey(
|
||
selectedSessionId: string | null | undefined,
|
||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
|
||
): string | null {
|
||
if (!selectedSessionId) {
|
||
return null
|
||
}
|
||
|
||
const row = sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))
|
||
|
||
return row ? sessionPinId(row) : selectedSessionId
|
||
}
|
||
|
||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||
* row the server omitted that we still want visible — both still-"working"
|
||
* sessions and pinned sessions.
|
||
*
|
||
* Two reasons the server drops a row we must keep:
|
||
*
|
||
* 1. A brand-new session's first user message isn't flushed to the SessionDB
|
||
* until its turn is persisted, so `listSessions(min_messages=1)` skips
|
||
* sessions that are mid-first-response. Because every `message.complete`
|
||
* triggers a full refresh, a hard replace makes concurrent new chats vanish
|
||
* the instant any one of them finishes.
|
||
* 2. The sidebar lists only the most-recent page (`SIDEBAR_SESSIONS_PAGE_SIZE`)
|
||
* ordered by activity. A pinned conversation that hasn't been touched in a
|
||
* while falls off that page, so a hard replace silently evicts it from the
|
||
* in-memory list — and because the Pinned section resolves pins against
|
||
* that list, the pin "disappears until you refresh".
|
||
*
|
||
* `keepIds` carries both the working set and the pinned set. Pins are stored
|
||
* on the durable lineage-root id (see {@link sessionPinId}), while the loaded
|
||
* row surfaces under its live compression tip, so we match a survivor by
|
||
* either its live `id` or its `_lineage_root_id`. Optimistic deletes/archives
|
||
* drop the row from `previous` (and unpin it), so a removed session can't be
|
||
* resurrected here. */
|
||
export function mergeSessionPage(
|
||
previous: SessionInfo[],
|
||
incoming: SessionInfo[],
|
||
keepIds: Iterable<string>
|
||
): SessionInfo[] {
|
||
const keep = keepIds instanceof Set ? keepIds : new Set(keepIds)
|
||
|
||
// Carry a known title onto a row that arrives title-less, so a freshly
|
||
// submitted session (e.g. a branch draft) holds its placeholder instead of
|
||
// flashing its raw message preview in the gap between persist and the async
|
||
// auto-titler. A real clear sets the local title null first, so this never
|
||
// masks one.
|
||
const prevById = new Map(previous.map(session => [session.id, session]))
|
||
// Tip rotation changes the live id — carry activity/title across the lineage
|
||
// root so a mid-turn refresh can't drop a touchSessionActivity bump.
|
||
const prevByLineage = new Map(previous.map(session => [session._lineage_root_id ?? session.id, session]))
|
||
|
||
const merged = incoming.map(session => {
|
||
const prev = prevById.get(session.id) ?? prevByLineage.get(session._lineage_root_id ?? session.id)
|
||
// User-send stamps last_active before the DB flushes the user row
|
||
// (last_active = MAX(messages.timestamp)). Keep the fresher of the two.
|
||
const last_active = Math.max(prev?.last_active ?? 0, session.last_active ?? 0)
|
||
const title = session.title?.trim() ? session.title : prev?.title?.trim() ? prev.title : session.title
|
||
|
||
return last_active === session.last_active && title === session.title ? session : { ...session, last_active, title }
|
||
})
|
||
|
||
if (keep.size === 0) {
|
||
return merged
|
||
}
|
||
|
||
const incomingIds = new Set(merged.map(session => session.id))
|
||
|
||
// Deduplicate by compression lineage: when auto-compression rotates the tip
|
||
// id (old #4 → new #5), the incoming page carries the new tip but the
|
||
// previous list still holds the old one. Without lineage-level dedup both
|
||
// rows survive as separate sidebar entries (fixes #43483).
|
||
const incomingLineageKeys = new Set(merged.map(session => session._lineage_root_id ?? session.id))
|
||
|
||
const survivors = previous.filter(
|
||
session =>
|
||
!incomingIds.has(session.id) &&
|
||
!incomingLineageKeys.has(session._lineage_root_id ?? session.id) &&
|
||
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
|
||
)
|
||
|
||
return survivors.length ? [...survivors, ...merged] : merged
|
||
}
|
||
|
||
/** Raise a session in recents on user send (before stream / turn resolve). */
|
||
export function touchSessionActivity(
|
||
sessionId: string | null | undefined,
|
||
options?: { at?: number; preview?: string }
|
||
): void {
|
||
const id = sessionId?.trim()
|
||
|
||
if (!id) {
|
||
return
|
||
}
|
||
|
||
const at = options?.at ?? Date.now() / 1000
|
||
const preview = options?.preview?.trim().slice(0, 200) || undefined
|
||
|
||
setSessions(prev => {
|
||
let changed = false
|
||
|
||
const next = prev.map(session => {
|
||
if (!sessionMatchesStoredId(session, id)) {
|
||
return session
|
||
}
|
||
|
||
const last_active = Math.max(session.last_active ?? 0, at)
|
||
|
||
if (last_active === session.last_active && (!preview || preview === session.preview)) {
|
||
return session
|
||
}
|
||
|
||
changed = true
|
||
|
||
return preview ? { ...session, last_active, preview } : { ...session, last_active }
|
||
})
|
||
|
||
return changed ? next : prev
|
||
})
|
||
}
|
||
|
||
export const $connection = atom<HermesConnection | null>(null)
|
||
export const $gatewayState = atom<ConnectionState>('idle')
|
||
export const $sessions = atom<SessionInfo[]>([])
|
||
// Cron-job sessions (source === 'cron') are fetched as their own list so the
|
||
// scheduler's always-newest sessions never crowd recents out of the page
|
||
// budget. Powers the collapsed "Cron jobs" sidebar section.
|
||
export const $cronSessions = atom<SessionInfo[]>([])
|
||
// Max cron sessions fetched for the sidebar section (single bounded page). When
|
||
// the fetch returns exactly this many rows we know more exist, so the section
|
||
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
|
||
// share one source of truth without a circular import.
|
||
export const CRON_SECTION_LIMIT = 50
|
||
// Messaging-platform sessions (telegram/discord/...) are fetched as their own
|
||
// slice — separate from local recents — so each platform renders a
|
||
// self-managed sidebar section and never interleaves with (or buries) local
|
||
// chats in the recents page. One combined fetch seeds every platform; a
|
||
// platform that exceeds this cap gets its own per-platform "load more".
|
||
export const $messagingSessions = atom<SessionInfo[]>([])
|
||
export const MESSAGING_SECTION_LIMIT = 100
|
||
// Exact per-platform conversation totals, keyed by source id. Empty until a
|
||
// per-platform "load more" fetch resolves it (the combined seed fetch only
|
||
// knows the aggregate), so sections fall back to their loaded count.
|
||
export const $messagingPlatformTotals = atom<Record<string, number>>({})
|
||
// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
|
||
// one platform may have more rows on disk than were loaded.
|
||
export const $messagingTruncated = atom<boolean>(false)
|
||
// Whether a profile's last session page was CAPPED by the request limit, keyed
|
||
// by profile name — i.e. more rows exist on disk than were loaded. Replaces the
|
||
// old exact per-profile totals: rendering `loaded/total` in the sidebar cost a
|
||
// COUNT(*) per profile DB on every refresh and only ever confused people, while
|
||
// "is there another page?" is what pagination actually needs and comes free
|
||
// from the row count the query already returned.
|
||
export const $sessionProfilesTruncated = atom<Record<string, boolean>>({})
|
||
export const $sessionsLoading = atom(true)
|
||
export const $activeSessionId = atom<string | null>(null)
|
||
export const $selectedStoredSessionId = atom<string | null>(null)
|
||
export interface ActiveSessionStoredIdRotation {
|
||
nextStoredSessionId: string
|
||
previousStoredSessionId: string
|
||
runtimeSessionId: string
|
||
}
|
||
|
||
// One-shot event for when auto-compression rotates the active runtime's stored
|
||
// id. Carrying the runtime + previous id is load-bearing: a bare next id cannot
|
||
// tell whether the user has already navigated away while React is waiting to
|
||
// run the route-following effect, which lets a background session steal the
|
||
// foreground route.
|
||
export const $activeSessionStoredIdRotation = atom<ActiveSessionStoredIdRotation | null>(null)
|
||
export const $messages = atom<ChatMessage[]>([])
|
||
|
||
// Streaming-stable derivations of $messages. During a token stream the array
|
||
// is replaced ~30×/s; components that only care about coarse facts (is the
|
||
// thread empty? is the tail a user message?) subscribe to these instead of
|
||
// $messages so per-token flushes don't re-render them — nanostores' `computed`
|
||
// only notifies when the derived VALUE changes.
|
||
export const $messagesEmpty = computed($messages, messages => messages.length === 0)
|
||
export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageIsUser)
|
||
|
||
export const $freshDraftReady = atom(false)
|
||
export const $busy = atom(false)
|
||
export const $awaitingResponse = atom(false)
|
||
// Stored-session id whose most recent resume FAILED terminally (the gateway RPC
|
||
// rejected AND the REST transcript fallback also failed), leaving the window
|
||
// with no runtime and an empty transcript. Drives use-route-resume's self-heal:
|
||
// while this matches the routed session the loader would otherwise latch
|
||
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
|
||
// resume on the next render/focus/reconnect instead of stranding the window.
|
||
// Null whenever the active route has a healthy (or in-flight) resume.
|
||
export const $resumeFailedSessionId = atom<string | null>(null)
|
||
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
|
||
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
|
||
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
|
||
// backoff window too: this fires only once auto-recovery has given up, so the
|
||
// chat view can swap the perpetual loader for an explicit error + manual Retry
|
||
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
|
||
// clears it and resets the retry counter. Null whenever the active route has a
|
||
// healthy, in-flight, or still-auto-retrying resume.
|
||
export const $resumeExhaustedSessionId = atom<string | null>(null)
|
||
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
|
||
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
|
||
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
|
||
export const $currentServiceTier = atom('')
|
||
export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
|
||
// Effective approval-bypass state mirrored from the gateway (session.info).
|
||
// Persistence lives in the backend config (approvals.mode), so this is a plain
|
||
// reflection of the truth the gateway reports rather than its own store.
|
||
export const $yoloActive = atom(false)
|
||
export const $currentCwd = atom(getRememberedWorkspaceCwd())
|
||
|
||
// Which conversation the live `$currentCwd` is known to describe. Three
|
||
// inhabitants, and the difference between the last two is load-bearing:
|
||
// a stored-session id (that conversation owns the path), `null` (the fresh-draft
|
||
// state, which MATCHES a null selection and therefore reads as OWNED — a draft's
|
||
// workspace is immediately usable), and the released marker
|
||
// `WORKSPACE_CWD_UNOWNED` below, which matches no selection and so reads as
|
||
// owned by nobody. `null` cannot double as the release value precisely because
|
||
// it matches: releasing to `null` while a draft is selected would hand the
|
||
// leftover path to the draft as its own workspace.
|
||
//
|
||
// A conversation switch publishes the new stored id immediately, but the new
|
||
// workspace only arrives when the resume settles, so for that whole window
|
||
// `$currentCwd` still holds the PREVIOUS conversation's folder. Without a way to
|
||
// say "this path is not this conversation's yet", workspace-derived surfaces
|
||
// treat the leftover path as authoritative and show the old repo's cached Git
|
||
// facts under the newly selected chat (#71254).
|
||
//
|
||
// Ownership, not emptiness, is what makes the switch atomic: clearing the path
|
||
// would collapse the workspace panes and drop file-tree state on every switch,
|
||
// so the path stays put and is simply marked as not-yet-owned.
|
||
export const $workspaceCwdOwner = atom<null | string>(null)
|
||
export const $newChatWorkspaceTarget = atom<NewChatWorkspaceTarget>(undefined)
|
||
export const $newChatWorkspaceTargetGeneration = atom(0)
|
||
export const $currentBranch = atom('')
|
||
export const $currentUsage = atom<UsageStats>({
|
||
calls: 0,
|
||
input: 0,
|
||
output: 0,
|
||
total: 0
|
||
})
|
||
export const $sessionStartedAt = atom<number | null>(null)
|
||
export const $turnStartedAt = atom<number | null>(null)
|
||
export const $introPersonality = atom('')
|
||
export const $currentPersonality = atom('')
|
||
export const $availablePersonalities = atom<string[]>([])
|
||
export const $introSeed = atom(0)
|
||
export const $contextSuggestions = atom<ContextSuggestion[]>([])
|
||
export const $modelPickerOpen = atom(false)
|
||
export const $sessionPickerOpen = atom(false)
|
||
|
||
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
|
||
export const setGatewayState = (next: Updater<ConnectionState>) => updateAtom($gatewayState, next)
|
||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
|
||
export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)
|
||
export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>
|
||
updateAtom($messagingPlatformTotals, next)
|
||
export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
|
||
export const setSessionProfilesTruncated = (next: Updater<Record<string, boolean>>) =>
|
||
updateAtom($sessionProfilesTruncated, next)
|
||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||
export const setActiveSessionStoredIdRotation = (next: Updater<ActiveSessionStoredIdRotation | null>) =>
|
||
updateAtom($activeSessionStoredIdRotation, next)
|
||
|
||
// Transient: a background session finished and the user hasn't opened it since.
|
||
// Written by session-states.ts (handleTransition), cleared here on session open.
|
||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||
|
||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
|
||
updateAtom($selectedStoredSessionId, next)
|
||
// Opening a session clears its unread state — the user is now looking at it.
|
||
const id = $selectedStoredSessionId.get()
|
||
|
||
if (id && $unreadFinishedSessionIds.get().includes(id)) {
|
||
$unreadFinishedSessionIds.set($unreadFinishedSessionIds.get().filter(x => x !== id))
|
||
}
|
||
}
|
||
|
||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
|
||
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
|
||
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||
|
||
export const setCurrentModel = (next: Updater<string>) => {
|
||
updateAtom($currentModel, next)
|
||
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
|
||
}
|
||
|
||
export const setCurrentProvider = (next: Updater<string>) => {
|
||
updateAtom($currentProvider, next)
|
||
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
|
||
}
|
||
|
||
export const getCurrentModelSource = (): ComposerModelSource => {
|
||
const source = storedString(COMPOSER_MODEL_SOURCE_KEY)
|
||
|
||
return source === 'default' || source === 'manual' ? source : ''
|
||
}
|
||
|
||
// Reactive mirror of the persisted source so UI (the composer pill's
|
||
// override badge) can subscribe. The getter above stays storage-backed —
|
||
// it's read cross-window, where this atom wouldn't see writes.
|
||
export const $currentModelSource = atom<ComposerModelSource>(getCurrentModelSource())
|
||
|
||
export const setCurrentModelSource = (source: ComposerModelSource) => {
|
||
persistString(COMPOSER_MODEL_SOURCE_KEY, source || null)
|
||
$currentModelSource.set(source)
|
||
}
|
||
|
||
// Monotonic intent token for async default refreshes. A profile/config request
|
||
// may start before the user opens the picker and finish after their click; the
|
||
// token lets that older response stand down even when the selected value is
|
||
// unchanged (value comparisons alone cannot detect re-selecting the same row).
|
||
let composerSelectionGeneration = 0
|
||
|
||
export const getComposerSelectionGeneration = (): number => composerSelectionGeneration
|
||
|
||
export const markComposerSelectionManual = (): void => {
|
||
composerSelectionGeneration += 1
|
||
setCurrentModelSource('manual')
|
||
}
|
||
|
||
export const setCurrentReasoningEffort = (next: Updater<string>) => {
|
||
updateAtom($currentReasoningEffort, next)
|
||
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
|
||
}
|
||
|
||
// The profile's `agent.reasoning_effort`, mirrored from config so surfaces that
|
||
// need to render or apply "the default" resolve the user's configured level
|
||
// instead of assuming DEFAULT_REASONING_EFFORT (lib/reasoning-effort). Empty
|
||
// until config loads, and re-seeded on every profile switch by useHermesConfig.
|
||
export const $defaultReasoningEffort = atom('')
|
||
|
||
export const setDefaultReasoningEffort = (next: string) => updateAtom($defaultReasoningEffort, next)
|
||
|
||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||
|
||
export const setCurrentFastMode = (next: Updater<boolean>) => {
|
||
updateAtom($currentFastMode, next)
|
||
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
|
||
}
|
||
|
||
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
|
||
|
||
export const setCurrentCwd = (next: Updater<string>) => {
|
||
updateAtom($currentCwd, next)
|
||
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
|
||
}
|
||
|
||
export const setCurrentCwdTransient = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||
|
||
// Released-ownership marker: the live path belongs to no conversation. `null`
|
||
// cannot serve as the release value because it MATCHES a fresh draft (whose
|
||
// selected id is also null), which would declare a leftover path to be the
|
||
// draft's own workspace — #71254, one selection over. Kept here beside the atom
|
||
// and the comparison so a release site cannot reinvent a subtly different value.
|
||
const WORKSPACE_CWD_UNOWNED = 'desktop:workspace-cwd-unowned'
|
||
|
||
/** Mark the live workspace as belonging to `storedSessionId`.
|
||
*
|
||
* Call this wherever a cwd is established for a conversation (resume settling,
|
||
* a warm switch, an explicit folder pick). Until it is called for the newly
|
||
* selected conversation, primary workspace-derived selectors hide the previous
|
||
* conversation's cached facts rather than publishing them (#71254).
|
||
*/
|
||
export const setWorkspaceCwdOwner = (storedSessionId: null | string) => updateAtom($workspaceCwdOwner, storedSessionId)
|
||
|
||
/** Declare that no conversation owns the live workspace path.
|
||
*
|
||
* For a conversation whose workspace is not known yet: the path on screen is
|
||
* provably still the previous conversation's, so workspace-derived surfaces must
|
||
* hide it rather than adopt it. The path itself is deliberately left alone —
|
||
* clearing it would collapse the workspace/review panes and drop file-tree
|
||
* state on every switch.
|
||
*/
|
||
export const releaseWorkspaceCwdOwner = () => updateAtom($workspaceCwdOwner, WORKSPACE_CWD_UNOWNED)
|
||
|
||
/** Commit `cwd` as the workspace of the conversation the user is looking at.
|
||
*
|
||
* The single primitive for "this path IS the selected conversation's" — a folder
|
||
* pick, a project entry, the agent relocating itself. Prefer it over a bare
|
||
* `setCurrentCwd`, which moves the path while leaving ownership naming whatever
|
||
* held it before; workspace-derived slices then stay hidden even though the
|
||
* path is correct (#71254).
|
||
*/
|
||
export const commitWorkspaceCwdForSelectedSession = (cwd: string) => {
|
||
setCurrentCwd(cwd)
|
||
setWorkspaceCwdOwner($selectedStoredSessionId.get())
|
||
}
|
||
|
||
/** True when `$currentCwd` is known to describe the selected conversation. */
|
||
export const workspaceCwdBelongsToSelectedSession = (): boolean =>
|
||
($workspaceCwdOwner.get() ?? null) === ($selectedStoredSessionId.get() ?? null)
|
||
|
||
export const setNewChatWorkspaceTarget = (next: NewChatWorkspaceTarget): number => {
|
||
const generation = $newChatWorkspaceTargetGeneration.get() + 1
|
||
$newChatWorkspaceTarget.set(next)
|
||
$newChatWorkspaceTargetGeneration.set(generation)
|
||
|
||
return generation
|
||
}
|
||
|
||
export const workspaceCwdForNewSession = (): string => {
|
||
if ($connection.get()?.mode === 'remote') {
|
||
return getRememberedWorkspaceCwd()
|
||
}
|
||
|
||
// A bare new chat starts DETACHED — no inherited cwd, so the composer's coding
|
||
// rail (which keys off $currentCwd) shows no branch and the first message runs
|
||
// in the gateway's default rather than silently in the last repo you touched.
|
||
// Only an explicit default-project-dir setting pre-attaches. Entering a
|
||
// project/worktree attaches its cwd directly (startSessionInWorkspace), so the
|
||
// "remember where I was when I'm in a project" case is unaffected.
|
||
return getConfiguredDefaultProjectDir()
|
||
}
|
||
|
||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
|
||
export const setTurnStartedAt = (next: Updater<number | null>) => updateAtom($turnStartedAt, next)
|
||
export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next)
|
||
export const setCurrentPersonality = (next: Updater<string>) => updateAtom($currentPersonality, next)
|
||
export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom($availablePersonalities, next)
|
||
export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next)
|
||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||
export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sessionPickerOpen, next)
|