diff --git a/apps/desktop/src/app/chat/session-status-dot.tsx b/apps/desktop/src/app/chat/session-status-dot.tsx index 1f457c10154dd..ab808866a8b91 100644 --- a/apps/desktop/src/app/chat/session-status-dot.tsx +++ b/apps/desktop/src/app/chat/session-status-dot.tsx @@ -66,6 +66,15 @@ const DOT_VARIANTS: Record = { role: 'status', title: r => r.finishedUnread }, + // Hollow grey, the faintest ink the app has — nothing has ever run here. It + // shares the outline with `background` because both mean "open, not + // producing", and sits a shade dimmer because a draft is the one state that + // has yet to do anything at all. + draft: { + ariaLabel: r => r.draftSession, + className: `${DOT_BASE} border border-(--ui-text-quaternary)`, + title: r => r.draftSession + }, // Settled: the project color, or nothing at all. An uncolored session used to // get a grey dot, which put a mark of the same weight as a status next to // every resting row and made "no color" look like a state of its own. @@ -78,8 +87,11 @@ export interface SessionStatusDotProps { /** The STORED session id — the key every live-state atom (working / * attention / stalled / unread / background) is keyed by, on BOTH surfaces: * the sidebar row's `session.id` and a pane tile's `storedSessionId` are the - * same stored id (`$workingSessionIds` et al. map `storedSessionId`). */ - storedSessionId: string + * same stored id (`$workingSessionIds` et al. map `storedSessionId`). + * + * Null on a new chat that has yet to reach the backend — no id to key by, + * and no turn behind it, which is the draft state by definition. */ + storedSessionId: null | string /** The session row for color resolution — recents OR the project tree. Both * call sites already hold it; passing it lets the idle dot inherit the * project color even for a session older than the paginated recents page @@ -112,7 +124,9 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa // Selector, not a plain useStore: the map is rebuilt whenever any session's // status changes, but a given dot only repaints when ITS OWN state flips. - const dotState = useStoreSelector($sessionDotStateById, states => states[storedSessionId] ?? 'idle') + const dotState = useStoreSelector($sessionDotStateById, states => + storedSessionId ? (states[storedSessionId] ?? 'idle') : 'draft' + ) const variant = DOT_VARIANTS[dotState] return ( diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 9a6853609d06c..e2663ecef881e 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1644,6 +1644,7 @@ export const ar = defineLocale({ needsInput: 'تحتاج إدخالا', waitingForAnswer: 'بانتظار إجابة', backgroundRunning: 'تعمل في الخلفية', + draftSession: 'مسودة — لم تُرسل بعد', finishedUnread: 'اكتملت وفيها جديد', hideTabBar: 'إخفاء شريط التبويبات', openInNewTab: 'فتح في تبويب جديد', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3c5ce76723559..cb3ac391fedb6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1966,6 +1966,7 @@ export const en: Translations = { waitingForAnswer: 'Waiting for your answer', finishedUnread: 'Finished — unread', backgroundRunning: 'Background task running', + draftSession: 'Draft — nothing sent yet', handoffOrigin: platform => `Handed off from ${platform}`, ownedByProfile: profile => `Profile: ${profile}`, renamed: 'Renamed', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 1d8ebb907e00f..8742c3f0dce8b 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1784,6 +1784,7 @@ export const ja = defineLocale({ waitingForAnswer: '回答を待っています', finishedUnread: '完了 — 未読', backgroundRunning: 'バックグラウンドタスク実行中', + draftSession: '下書き — 未送信', handoffOrigin: platform => `${platform} から引き継ぎ`, ownedByProfile: profile => `プロファイル: ${profile}`, renamed: '名前を変更しました', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index c68958353600f..6a33c3631a52b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1658,6 +1658,7 @@ export interface Translations { waitingForAnswer: string finishedUnread: string backgroundRunning: string + draftSession: string handoffOrigin: (platform: string) => string ownedByProfile: (profile: string) => string renamed: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index b614fe8abbfd8..977eacb4e4077 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1726,6 +1726,7 @@ export const zhHant = defineLocale({ waitingForAnswer: '等待您的回答', finishedUnread: '已完成 — 未讀', backgroundRunning: '背景任務執行中', + draftSession: '草稿 — 尚未傳送', handoffOrigin: platform => `從 ${platform} 轉接`, ownedByProfile: profile => `設定檔:${profile}`, renamed: '已重新命名', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 92b7bf5cd109c..45c1b7bb7aa8c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2158,6 +2158,7 @@ export const zh: Translations = { waitingForAnswer: '正在等待你的回答', finishedUnread: '已完成 — 未读', backgroundRunning: '后台任务运行中', + draftSession: '草稿 — 尚未发送', handoffOrigin: platform => `从 ${platform} 转接`, ownedByProfile: profile => `配置档:${profile}`, renamed: '已重命名', diff --git a/apps/desktop/src/store/session-dot-state.ts b/apps/desktop/src/store/session-dot-state.ts index 3a27e41172171..2d8a56a0f7575 100644 --- a/apps/desktop/src/store/session-dot-state.ts +++ b/apps/desktop/src/store/session-dot-state.ts @@ -23,9 +23,9 @@ import { stableRecord } from '@/lib/stable-array' import { $backgroundRunningSessionIds } from './composer-status' import { $sessions, $unreadFinishedSessionIds, lineageAliases } from './session' -import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from './session-states' +import { $attentionSessionIds, $draftSessionIds, $stalledSessionIds, $workingSessionIds } from './session-states' -export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working' +export type SessionDotState = 'background' | 'draft' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working' /** The sidebar row's arc. A quiet turn is still authoritatively running, so * `stalled` keeps it; a blocking prompt drops it, because the amber dot is the @@ -46,9 +46,10 @@ export const $sessionDotStateById = computed( $stalledSessionIds, $backgroundRunningSessionIds, $unreadFinishedSessionIds, + $draftSessionIds, $sessions ], - (attention, working, stalled, background, unread, sessions) => { + (attention, working, stalled, background, unread, draft, sessions) => { const next: Record = {} const claim = (ids: readonly string[], state: SessionDotState) => { @@ -62,6 +63,10 @@ export const $sessionDotStateById = computed( // Weakest claim first — each pass overwrites the one above it, so the order // below IS the priority order. A blocking prompt outranks everything: it is // the only state that needs the user. + // + // Draft is weakest of all: it says only "no turn has happened here yet", so + // the first thing that does happen speaks over it. + claim(draft, 'draft') claim(unread, 'unread') claim(background, 'background') claim(working, 'working') diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index d127d6bcfc583..79020f0450bbd 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -39,6 +39,7 @@ import { $sessions, $unreadFinishedSessionIds, lineageAliases, + sessionMatchesStoredId, setActiveSessionStoredIdRotation } from './session' import { isSecondaryWindow } from './windows' @@ -308,6 +309,38 @@ export const $attentionSessionIds = computed( )) ) +// An open session nothing has ever been sent to — the ⌘T tab whose backend +// session exists but is unlisted, or a tile still waiting on its first send. +// `blankDraftTile`'s predicate, read as a status rather than as a slot to spend. +// +// The row's own `message_count` is the tiebreaker, and it is load-bearing: a +// session RESUMING also holds an empty message list for the moment between +// binding its runtime and loading its transcript, and calling that a draft +// would flash the wrong mark on a conversation with years of history in it. +let draftIds: readonly string[] = [] +export const $draftSessionIds = computed([$sessionStates, $sessions], (states, sessions) => { + const unsent = (state: ClientSessionState) => { + if (state.busy || state.messages.length > 0) { + return false + } + + const storedId = state.storedSessionId + + // No stored id is the ⌘T tab that hasn't reached the backend yet: a draft + // by definition, and no row to consult. Asking anyway would match a row on + // an empty lineage root. + if (!storedId) { + return true + } + + const row = sessions.find(session => sessionMatchesStoredId(session, storedId)) + + return !row || row.message_count === 0 + } + + return (draftIds = stableArray(draftIds, storedIds(states, sessions, unsent))) +}) + // --------------------------------------------------------------------------- // Session tiles. // ---------------------------------------------------------------------------