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:
parent
26b3918dd9
commit
5b3a5ccae6
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -1644,6 +1644,7 @@ export const ar = defineLocale({
|
|||
needsInput: 'تحتاج إدخالا',
|
||||
waitingForAnswer: 'بانتظار إجابة',
|
||||
backgroundRunning: 'تعمل في الخلفية',
|
||||
draftSession: 'مسودة — لم تُرسل بعد',
|
||||
finishedUnread: 'اكتملت وفيها جديد',
|
||||
hideTabBar: 'إخفاء شريط التبويبات',
|
||||
openInNewTab: 'فتح في تبويب جديد',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1784,6 +1784,7 @@ export const ja = defineLocale({
|
|||
waitingForAnswer: '回答を待っています',
|
||||
finishedUnread: '完了 — 未読',
|
||||
backgroundRunning: 'バックグラウンドタスク実行中',
|
||||
draftSession: '下書き — 未送信',
|
||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||
ownedByProfile: profile => `プロファイル: ${profile}`,
|
||||
renamed: '名前を変更しました',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1726,6 +1726,7 @@ export const zhHant = defineLocale({
|
|||
waitingForAnswer: '等待您的回答',
|
||||
finishedUnread: '已完成 — 未讀',
|
||||
backgroundRunning: '背景任務執行中',
|
||||
draftSession: '草稿 — 尚未傳送',
|
||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||
ownedByProfile: profile => `設定檔:${profile}`,
|
||||
renamed: '已重新命名',
|
||||
|
|
|
|||
|
|
@ -2158,6 +2158,7 @@ export const zh: Translations = {
|
|||
waitingForAnswer: '正在等待你的回答',
|
||||
finishedUnread: '已完成 — 未读',
|
||||
backgroundRunning: '后台任务运行中',
|
||||
draftSession: '草稿 — 尚未发送',
|
||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||
ownedByProfile: profile => `配置档:${profile}`,
|
||||
renamed: '已重命名',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue