feat(desktop): mark an unsent session with its own status dot

A draft got no dot at all, so the one tab that has never done anything
looked identical to a settled session. Give it the faintest mark the app
has — a hollow outline, weakest claim in the dot's priority order, so the
first thing that actually happens speaks over it.

The row's own message_count is the tiebreaker for what counts as a draft:
a session RESUMING also holds an empty message list for a moment, and
calling that a draft flashes the wrong mark on a conversation with years
of history in it.
This commit is contained in:
Brooklyn Nicholson 2026-08-09 03:41:50 -05:00
parent 26b3918dd9
commit 5b3a5ccae6
9 changed files with 64 additions and 6 deletions

View File

@ -66,6 +66,15 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
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 (

View File

@ -1644,6 +1644,7 @@ export const ar = defineLocale({
needsInput: 'تحتاج إدخالا',
waitingForAnswer: 'بانتظار إجابة',
backgroundRunning: 'تعمل في الخلفية',
draftSession: 'مسودة — لم تُرسل بعد',
finishedUnread: 'اكتملت وفيها جديد',
hideTabBar: 'إخفاء شريط التبويبات',
openInNewTab: 'فتح في تبويب جديد',

View File

@ -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',

View File

@ -1784,6 +1784,7 @@ export const ja = defineLocale({
waitingForAnswer: '回答を待っています',
finishedUnread: '完了 — 未読',
backgroundRunning: 'バックグラウンドタスク実行中',
draftSession: '下書き — 未送信',
handoffOrigin: platform => `${platform} から引き継ぎ`,
ownedByProfile: profile => `プロファイル: ${profile}`,
renamed: '名前を変更しました',

View File

@ -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

View File

@ -1726,6 +1726,7 @@ export const zhHant = defineLocale({
waitingForAnswer: '等待您的回答',
finishedUnread: '已完成 — 未讀',
backgroundRunning: '背景任務執行中',
draftSession: '草稿 — 尚未傳送',
handoffOrigin: platform => `${platform} 轉接`,
ownedByProfile: profile => `設定檔:${profile}`,
renamed: '已重新命名',

View File

@ -2158,6 +2158,7 @@ export const zh: Translations = {
waitingForAnswer: '正在等待你的回答',
finishedUnread: '已完成 — 未读',
backgroundRunning: '后台任务运行中',
draftSession: '草稿 — 尚未发送',
handoffOrigin: platform => `${platform} 转接`,
ownedByProfile: profile => `配置档:${profile}`,
renamed: '已重命名',

View File

@ -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<string, SessionDotState> = {}
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')

View File

@ -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.
// ---------------------------------------------------------------------------