refactor(web): drop the permission RPC pair and turn-anchoring machinery

The session.permissions/setPermission unary pair, the PermissionOption wire
DTO, the client Session wrappers, and the fixture/fake mirrors all leave the
wire: the read side moves to the 'permissions' session projection and the
write side moves to the /permission command in follow-up commits, so the
web protocol gains no permission methods at all.

The pendingSwitches + prompt-submit flush + hasOpenTurn move also goes.
Knob events no longer need turn enclosure: the persistence scanner keeps
standalone events after the last turn/end as part of the preserved prefix
(remove-synthetic-log-only-turns), none of the three knob invariants demand
an open turn, and the setters append bare events. An idle switch commits
immediately; hasOpenTurn stays a user-approval private fold (its audit pair
is the one contract that still requires enclosure).

The old PermissionSelect chip and its mount-time fetch die with the RPCs
(the resident composer broke the mount-once assumption); the projection-fed
replacement lands with the Access seat swap.
This commit is contained in:
imccyu
2026-07-28 21:35:26 +08:00
parent a66d1e335f
commit c6b552e817
36 changed files with 37 additions and 561 deletions

View File

@@ -7,7 +7,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -483,12 +483,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
let approvalPending = true
const pendingQuestionRpcId = mint()
let questionPending = true
/** Per-session permission preset (fixture mirror of the host permission select). */
const permissionValues = new Map<SessionId, string>()
const PERMISSION_OPTIONS = [
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
]
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
{
id: 'harness-profile',
@@ -839,24 +833,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
return ok(request, { accepted: true as const })
},
permissions: (request) => {
const { sessionId: id } = request.payload
if (summaryOf(id) === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
return ok(request, { options: PERMISSION_OPTIONS, currentValue: permissionValues.get(id) ?? 'workspace-write' })
},
setPermission: (request) => {
const { sessionId: id, value } = request.payload
if (summaryOf(id) === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
if (!PERMISSION_OPTIONS.some(option => option.value === value)) {
return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } })
}
permissionValues.set(id, value)
return ok(request, { currentValue: value })
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
@@ -1134,8 +1110,6 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'session.permissions': return this.api.sessions.permissions(request)
case 'session.setPermission': return this.api.sessions.setPermission(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)

View File

@@ -12,7 +12,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -63,12 +63,6 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPermissions: (payload: unknown) =>
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
payload => Promise.resolve(ok({ currentValue: payload.value }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
@@ -92,8 +86,6 @@ export class FakeApiClient implements IApiClient {
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)),
setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -345,23 +345,6 @@ describe('createFixtureApi', () => {
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
})
it('permissions/setPermission mirror the host select: read, switch, validation', async () => {
const api = createFixtureApi()
const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') }))
expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } })
const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' }))
expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } })
const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') }))
expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } })
// Validation: ghost session and unknown value.
const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') }))
expect(ghostRead.result.ok).toBe(false)
const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' }))
expect(ghostSwitch.result.ok).toBe(false)
const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' }))
expect(unknown.result.ok).toBe(false)
})
it('describe answers the fixture identity', async () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
@@ -739,8 +722,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' })

View File

@@ -40,15 +40,7 @@ export type { PendingInteraction, PendingKind, PendingPayloads } from './session
export type {
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
} from './sessions/projection-store.ts'
export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** The permission select material as the object layer serves it to UI plugins. */
export interface PermissionSelect {
/** Switchable presets plus (when derived) the current-only `custom`. */
options: { value: string; name: string; description?: string }[]
/** The effective current value (`custom` when knobs match no preset). */
currentValue: string
}
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Client-side Cordis context after declaration merging. */
export type ClientContext = Context

View File

@@ -248,31 +248,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Read the permission select (options + effective current value).
* @returns the select material, or the error branch on failure.
*/
async permissions(): Promise<RpcResult<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> {
try {
return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result
} catch (error) {
return transportError(error)
}
}
/**
* Switch the permission preset.
* @param value - a preset value advertised by {@link Session.permissions} (never `custom`).
* @returns the confirmed current value, or the error branch on failure.
*/
async setPermission(value: string): Promise<RpcResult<{ currentValue: string }>> {
try {
return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result
} catch (error) {
return transportError(error)
}
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()

View File

@@ -81,12 +81,6 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPermissions: (payload: unknown) =>
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
payload => Promise.resolve(ok({ currentValue: payload.value }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -111,8 +105,6 @@ export class FakeApiClient implements IApiClient {
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)),
setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -358,7 +358,7 @@ describe('connected generation', () => {
describe('waiting-approval list bit', () => {
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
@@ -371,7 +371,7 @@ describe('waiting-approval list bit', () => {
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
@@ -386,7 +386,7 @@ describe('waiting-approval list bit', () => {
it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleConnected() // resolved-while-disconnected questions send no frame

View File

@@ -816,27 +816,3 @@ describe('reference stability (the memo contract)', () => {
expect(resolved.pending).toBe(after.pending)
})
})
describe('permissions / setPermission', () => {
it('passes the select read and switch through with the session id', async () => {
const { api, session } = makeSession()
api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }))
const read = await session.permissions()
expect(read.ok).toBe(true)
if (read.ok) expect(read.value.currentValue).toBe('workspace-write')
expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }])
const switched = await session.setPermission('danger-full-access')
expect(switched.ok).toBe(true)
if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access')
expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }])
})
it('folds transport failures into the error branch', async () => {
const { api, session } = makeSession()
api.onPermissions = () => Promise.reject(new Error('down'))
api.onSetPermission = () => Promise.reject(new Error('down'))
expect((await session.permissions()).ok).toBe(false)
expect((await session.setPermission('x')).ok).toBe(false)
})
})

View File

@@ -1,49 +0,0 @@
/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a
quiet text chip with a chevron; hover paints the standard interactive pill.
The native select is stretched invisibly over the chip so the platform
dropdown does the menu work — keyboard/AT semantics come free. */
.root {
position: relative;
display: inline-flex;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 8px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 20px;
pointer-events: none; /* the overlaid select owns the interaction */
}
.root:hover .chip {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
color: var(--dsw-alias-label-caption);
}
/* Invisible native select stretched over the chip: real menu, zero drawing. */
.select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
cursor: pointer;
}
.select:disabled {
cursor: default;
}
.root:has(.select:disabled) .chip {
opacity: 0.5;
}

View File

@@ -1,88 +0,0 @@
// PermissionSelect: the composer bottom-row permission chip (draft
// start.jpeg's `Read-only ∨` control). Options and the current value load on
// mount from the injected permissions() callback; empty options
// (permission-less host composition) render nothing. The visible chip is
// presentation only — an invisible native select stretched over it owns the
// menu and interaction. A switch disables the control until the host
// confirms, then adopts the confirmed value (`custom` is shown as the current
// value but never offered as a target — the host already omits it from
// switchable options; a stale-select failure restores the previous value).
import { useEffect, useRef, useState } from 'react'
import type { PermissionSelect as PermissionSelectData } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PermissionSelect.module.css'
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`). Presentation-only — the wire
* vocabulary and the host's advertised names are untouched; a host-configured
* name that is not kebab-case (contains spaces or uppercase) passes through.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
export interface PermissionSelectProps {
/** Read the select material; null hides the control. */
permissions: () => Promise<PermissionSelectData | null>
/** Switch the preset; resolves the confirmed value, or null on failure. */
setPermission: (value: string) => Promise<string | null>
}
export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) {
const [data, setData] = useState<PermissionSelectData | null>(null)
const [switching, setSwitching] = useState(false)
// Unmount guard: the load/switch promises outlive a session switch's remount.
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
void permissions().then((loaded) => {
if (aliveRef.current) setData(loaded)
})
return () => {
aliveRef.current = false
}
}, [permissions])
if (data === null) return null
const onChange = (value: string): void => {
if (value === data.currentValue) return
setSwitching(true)
const previous = data
setData({ ...data, currentValue: value })
void setPermission(value).then((confirmed) => {
if (!aliveRef.current) return
setSwitching(false)
if (confirmed === null) setData(previous)
else setData({ ...previous, currentValue: confirmed })
})
}
const current = data.options.find(option => option.value === data.currentValue)
return (
<label className={css.root} title={current?.description}>
<span className={css.chip}>
{displayName(current?.name ?? data.currentValue)}
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
<select
className={css.select}
aria-label="权限策略"
value={data.currentValue}
disabled={switching}
onChange={(e) => { onChange(e.target.value) }}
>
{data.options.map(option => (
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
{displayName(option.name)}
</option>
))}
</select>
</label>
)
}

View File

@@ -55,7 +55,7 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
current: ROOT,
phase: 'ready',
})

View File

@@ -26,8 +26,8 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 2 },
},
current: undefined,
phase: 'ready',

View File

@@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) {
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
})

View File

@@ -68,7 +68,7 @@ async function bench(nodes: ToolResultNode[]) {
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
})

View File

@@ -94,7 +94,7 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
})

View File

@@ -64,8 +64,8 @@ function mount(
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
},
current: SID,
phase: 'ready',

View File

@@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **State dots have three live data states (running/amber approval-waiting/none)** — the done/error sources arrive with notifications; the four-color primitive is already wired.
- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired.
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.

View File

@@ -94,17 +94,4 @@ describe('SidebarRoot shell', () => {
expect(b.regionOwner().wide).toBe(false)
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
})
it('waiting-approval shows the amber warning dot and outranks the running ring', () => {
mount(
summary({ id: 'blocked', title: 'blocked one', cwd: '/p', running: true, waitingApproval: true, updatedAt: 2 }),
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 1 }),
)
act(() => { fireEvent.click(screen.getByText('p')) })
const blockedRow = screen.getByText('blocked one').closest('[role="treeitem"]')!
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
expect(blockedRow.querySelector('[data-state="warning"]')).toBeTruthy()
expect(blockedRow.querySelector('[data-state="ongoing"]')).toBeNull()
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
})
})

View File

@@ -149,8 +149,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>,
)
}

View File

@@ -8,7 +8,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts'
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
})
const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),

View File

@@ -15,7 +15,7 @@ beforeEach(() => { localStorage.clear() })
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides,
})
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id),