feat(client): improve workspace session browsing
This commit is contained in:
@@ -1387,10 +1387,11 @@ interface FixtureWorld {
|
|||||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||||
// The resident fixture sessions all carry history, so none of them is blank.
|
// The resident fixture sessions all carry history, so none of them is blank.
|
||||||
|
const fixtureSessionsNow = Date.now()
|
||||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
{ sessionId: sid('fx-alpha'), createdAt: fixtureSessionsNow - 180_000, updatedAt: fixtureSessionsNow, running: true, blank: false, cwd: '/tmp/fixture' },
|
||||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
{ sessionId: sid('fx-beta'), createdAt: fixtureSessionsNow - 120_000, updatedAt: fixtureSessionsNow - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
{ sessionId: sid('fx-gamma'), createdAt: fixtureSessionsNow - 60_000, updatedAt: fixtureSessionsNow - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||||
]
|
]
|
||||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||||
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
||||||
@@ -2046,15 +2047,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
|||||||
return ok(request, { sessionId: requestedId })
|
return ok(request, { sessionId: requestedId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const createdAt = Date.now()
|
||||||
const created: SessionSummary = {
|
const created: SessionSummary = {
|
||||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
sessionId: requestedId ?? sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: true, cwd,
|
||||||
}
|
}
|
||||||
sessions.push(created)
|
sessions.push(created)
|
||||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||||
attachedSessions += 1
|
attachedSessions += 1
|
||||||
const emitSession = (): void => {
|
const emitSession = (): void => {
|
||||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
|
emitHost({ type: 'host/session-added', sessionId: created.sessionId, createdAt, blank: true, cwd })
|
||||||
}
|
}
|
||||||
if (workspace !== undefined && options.failWorkspaceAttach) {
|
if (workspace !== undefined && options.failWorkspaceAttach) {
|
||||||
emitSession()
|
emitSession()
|
||||||
@@ -2121,15 +2123,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
|||||||
}
|
}
|
||||||
let cut = boundary.seq + 1
|
let cut = boundary.seq + 1
|
||||||
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
|
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
|
||||||
|
const createdAt = Date.now()
|
||||||
const child: SessionSummary = {
|
const child: SessionSummary = {
|
||||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
|
sessionId: sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: false,
|
||||||
parentSessionId: sessionId,
|
parentSessionId: sessionId,
|
||||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||||
}
|
}
|
||||||
logs.set(child.sessionId, log.slice(0, cut))
|
logs.set(child.sessionId, log.slice(0, cut))
|
||||||
sessions.push(child)
|
sessions.push(child)
|
||||||
emitHost({
|
emitHost({
|
||||||
type: 'host/session-added', sessionId: child.sessionId, blank: false,
|
type: 'host/session-added', sessionId: child.sessionId, createdAt, blank: false,
|
||||||
parentSessionId: sessionId,
|
parentSessionId: sessionId,
|
||||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface SessionsPortSummary {
|
|||||||
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
|
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
|
||||||
blank: boolean
|
blank: boolean
|
||||||
cwd?: string
|
cwd?: string
|
||||||
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface TitledSessionSummary extends SessionSummary {
|
|||||||
export interface SessionListEntry {
|
export interface SessionListEntry {
|
||||||
sessionId: SessionId
|
sessionId: SessionId
|
||||||
title?: string
|
title?: string
|
||||||
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
running: boolean
|
running: boolean
|
||||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||||
@@ -77,6 +78,7 @@ export function flattenLineage(
|
|||||||
const pendingInteraction = pendingInteractions?.get(s.sessionId)
|
const pendingInteraction = pendingInteractions?.get(s.sessionId)
|
||||||
out.push({
|
out.push({
|
||||||
...s,
|
...s,
|
||||||
|
createdAt: s.createdAt ?? s.updatedAt,
|
||||||
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
|
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
|
||||||
completed: completed?.has(s.sessionId) ?? false,
|
completed: completed?.has(s.sessionId) ?? false,
|
||||||
depth,
|
depth,
|
||||||
|
|||||||
@@ -540,8 +540,9 @@ export class SessionManager {
|
|||||||
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
||||||
const { result } = await this.api.sessions.create(payload)
|
const { result } = await this.api.sessions.create(payload)
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
const createdAt = Date.now()
|
||||||
this.recordMutation({ kind: 'upsert', summary: {
|
this.recordMutation({ kind: 'upsert', summary: {
|
||||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
sessionId: result.value.sessionId, createdAt, updatedAt: createdAt, running: false, blank: true,
|
||||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||||
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
|
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
|
||||||
} })
|
} })
|
||||||
@@ -551,9 +552,11 @@ export class SessionManager {
|
|||||||
// so expose it immediately as Ungrouped while the caller keeps the
|
// so expose it immediately as Ungrouped while the caller keeps the
|
||||||
// prompt buffer and decides whether to retry attachment.
|
// prompt buffer and decides whether to retry attachment.
|
||||||
if (publishedSessionId !== undefined) {
|
if (publishedSessionId !== undefined) {
|
||||||
|
const createdAt = Date.now()
|
||||||
this.recordMutation({ kind: 'upsert', summary: {
|
this.recordMutation({ kind: 'upsert', summary: {
|
||||||
sessionId: publishedSessionId,
|
sessionId: publishedSessionId,
|
||||||
updatedAt: Date.now(),
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
running: false,
|
running: false,
|
||||||
blank: true,
|
blank: true,
|
||||||
} })
|
} })
|
||||||
@@ -587,8 +590,9 @@ export class SessionManager {
|
|||||||
? result.value.sessionId
|
? result.value.sessionId
|
||||||
: workspaceAttachSessionId(result.error)
|
: workspaceAttachSessionId(result.error)
|
||||||
if (childId !== undefined) {
|
if (childId !== undefined) {
|
||||||
|
const createdAt = Date.now()
|
||||||
this.recordMutation({ kind: 'upsert', summary: {
|
this.recordMutation({ kind: 'upsert', summary: {
|
||||||
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
|
sessionId: childId, createdAt, updatedAt: createdAt, running: false, blank: false,
|
||||||
parentSessionId: opts.sessionId,
|
parentSessionId: opts.sessionId,
|
||||||
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
||||||
} })
|
} })
|
||||||
@@ -615,8 +619,9 @@ export class SessionManager {
|
|||||||
* @param agentPreset - the preset id the host confirmed.
|
* @param agentPreset - the preset id the host confirmed.
|
||||||
*/
|
*/
|
||||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||||
|
const createdAt = Date.now()
|
||||||
this.recordMutation({ kind: 'upsert', summary: {
|
this.recordMutation({ kind: 'upsert', summary: {
|
||||||
sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
|
sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, agentPreset,
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,8 +788,10 @@ export class SessionManager {
|
|||||||
const frame = envelope.payload
|
const frame = envelope.payload
|
||||||
switch (frame.type) {
|
switch (frame.type) {
|
||||||
case 'host/session-added': {
|
case 'host/session-added': {
|
||||||
|
const createdAt = frame.createdAt ?? Date.now()
|
||||||
this.mergeSummary({
|
this.mergeSummary({
|
||||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
sessionId: frame.sessionId, createdAt, updatedAt: createdAt,
|
||||||
|
running: false, blank: frame.blank,
|
||||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||||
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
||||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||||
@@ -1037,7 +1044,8 @@ export class SessionManager {
|
|||||||
const items = fresh.map((entry) => {
|
const items = fresh.map((entry) => {
|
||||||
const prev = this.entryCache.get(entry.sessionId)
|
const prev = this.entryCache.get(entry.sessionId)
|
||||||
if (
|
if (
|
||||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
prev !== undefined && prev.createdAt === entry.createdAt
|
||||||
|
&& prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||||
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
|
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
|
||||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface SessionSummary {
|
|||||||
* selected blank entry.
|
* selected blank entry.
|
||||||
*/
|
*/
|
||||||
blank: boolean
|
blank: boolean
|
||||||
|
/** Durable session creation time. */
|
||||||
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
/** Current host-computed projection values retained by the object layer. */
|
/** Current host-computed projection values retained by the object layer. */
|
||||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||||
@@ -667,6 +669,7 @@ export class SessionsService implements ISessions {
|
|||||||
running: entry.running,
|
running: entry.running,
|
||||||
...(entry.completed ? { completed: true } : {}),
|
...(entry.completed ? { completed: true } : {}),
|
||||||
blank: entry.blank,
|
blank: entry.blank,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
updatedAt: entry.updatedAt,
|
updatedAt: entry.updatedAt,
|
||||||
...(entry.pendingInteraction === undefined
|
...(entry.pendingInteraction === undefined
|
||||||
? {}
|
? {}
|
||||||
@@ -700,6 +703,7 @@ export class SessionsService implements ISessions {
|
|||||||
origin: 'subagent',
|
origin: 'subagent',
|
||||||
running: child.activity === 'running',
|
running: child.activity === 'running',
|
||||||
blank: false,
|
blank: false,
|
||||||
|
createdAt: 0,
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
}
|
}
|
||||||
} else if (summary.displayTitle !== displayTitle) {
|
} else if (summary.displayTitle !== displayTitle) {
|
||||||
|
|||||||
@@ -115,6 +115,15 @@
|
|||||||
background: var(--dsw-alias-interactive-bg-hover);
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.denseList .item {
|
||||||
|
min-height: 34px;
|
||||||
|
padding-block: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.denseList .label {
|
||||||
|
padding-block: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.list.compactList,
|
.list.compactList,
|
||||||
.submenu.compactList {
|
.submenu.compactList {
|
||||||
min-width: 164px;
|
min-width: 164px;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|||||||
* @param props.anchor - the trigger element (rendered in place).
|
* @param props.anchor - the trigger element (rendered in place).
|
||||||
* @param props.items - selectable rows and optional separators.
|
* @param props.items - selectable rows and optional separators.
|
||||||
* @param props.selectedId - row shown as selected.
|
* @param props.selectedId - row shown as selected.
|
||||||
|
* @param props.selectedIds - rows shown as selected when a menu contains independent option groups.
|
||||||
* @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children).
|
* @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children).
|
||||||
* @param props.onClose - invoked on outside click or Escape.
|
* @param props.onClose - invoked on outside click or Escape.
|
||||||
* @param props.align - list alignment against the anchor (default 'start').
|
* @param props.align - list alignment against the anchor (default 'start').
|
||||||
@@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|||||||
* both trigger and list for the pointer grace (default false keeps it open
|
* both trigger and list for the pointer grace (default false keeps it open
|
||||||
* until outside click/Escape/selection). The grace makes the 4px trigger->list
|
* until outside click/Escape/selection). The grace makes the 4px trigger->list
|
||||||
* gap and a brief overshoot survivable; coming back cancels the close.
|
* gap and a brief overshoot survivable; coming back cancels the close.
|
||||||
|
* @param props.dense - reduce vertical row spacing without changing the standard typography or card width.
|
||||||
* @param props.compact - use reduced menu typography and spacing.
|
* @param props.compact - use reduced menu typography and spacing.
|
||||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||||
@@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|||||||
* by a hairline; they stay visible while the items above scroll.
|
* by a hairline; they stay visible while the items above scroll.
|
||||||
* @returns anchor wrapper with the conditional list.
|
* @returns anchor wrapper with the conditional list.
|
||||||
*/
|
*/
|
||||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
|
export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: {
|
||||||
open: boolean
|
open: boolean
|
||||||
anchor: ReactNode
|
anchor: ReactNode
|
||||||
items: readonly MenuEntry[]
|
items: readonly MenuEntry[]
|
||||||
footer?: readonly MenuEntry[]
|
footer?: readonly MenuEntry[]
|
||||||
selectedId?: string | undefined
|
selectedId?: string | undefined
|
||||||
|
selectedIds?: readonly string[] | undefined
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
align?: 'start' | 'end'
|
align?: 'start' | 'end'
|
||||||
side?: 'bottom' | 'top' | 'right'
|
side?: 'bottom' | 'top' | 'right'
|
||||||
portal?: boolean
|
portal?: boolean
|
||||||
closeOnPointerLeave?: boolean
|
closeOnPointerLeave?: boolean
|
||||||
|
dense?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
getAnchorRect?: () => DOMRect | null
|
getAnchorRect?: () => DOMRect | null
|
||||||
className?: string
|
className?: string
|
||||||
@@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
|||||||
}
|
}
|
||||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||||
const subOpen = hasSub && openSubmenuId === entry.id
|
const subOpen = hasSub && openSubmenuId === entry.id
|
||||||
|
const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
@@ -214,7 +219,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
className={clsx(css.item, selected && css.selected, entry.danger === true && css.danger)}
|
||||||
disabled={entry.disabled}
|
disabled={entry.disabled}
|
||||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||||
aria-expanded={hasSub ? subOpen : undefined}
|
aria-expanded={hasSub ? subOpen : undefined}
|
||||||
@@ -230,7 +235,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
|||||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||||
<span className={css.itemLabel}>{entry.label}</span>
|
<span className={css.itemLabel}>{entry.label}</span>
|
||||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
{selected && <IconCheckOutline16 className={css.check} />}
|
||||||
</button>
|
</button>
|
||||||
{subOpen && entry.submenu !== undefined && (
|
{subOpen && entry.submenu !== undefined && (
|
||||||
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
|
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
|
||||||
@@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
|||||||
const list = open && (
|
const list = open && (
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
className={clsx(css.list, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||||
role="menu"
|
role="menu"
|
||||||
// React portals bubble synthetic events through the REACT tree: without
|
// React portals bubble synthetic events through the REACT tree: without
|
||||||
|
|||||||
@@ -194,14 +194,14 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
/* Bottom fade: compact overlay pinned to the visible bottom,
|
||||||
transparent -> sidebar fill so it tracks the theme. */
|
transparent -> sidebar fill so it tracks the theme. */
|
||||||
.fade {
|
.fade {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: var(--dsh-session-list-edge-inset);
|
right: var(--dsh-session-list-edge-inset);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 72px;
|
height: 24px;
|
||||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -229,9 +229,9 @@
|
|||||||
- var(--dsh-session-list-scrollbar-width)
|
- var(--dsh-session-list-scrollbar-width)
|
||||||
- var(--dsh-session-list-scrollbar-offset)
|
- var(--dsh-session-list-scrollbar-offset)
|
||||||
);
|
);
|
||||||
/* Clears the 72px bottom fade overlay: at scroll end the last row sits
|
/* Clears the compact bottom fade overlay: at scroll end the last row sits
|
||||||
above the gradient instead of under it. */
|
above the gradient instead of under it. */
|
||||||
padding-bottom: 48px;
|
padding-bottom: 16px;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +258,24 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sessionOverflowButton {
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px 0 28px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionOverflowButton:hover {
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* The workspace/session browsing region filling the sidebar shell's
|
* The workspace/session browsing region filling the sidebar shell's
|
||||||
* `sidebar.workspaces` hole: section header (title + group-by + add
|
* `sidebar.workspaces` hole: section header (title + view options + add
|
||||||
* workspace), search, the grouped tree or flat list, and the workspace
|
* workspace), search, the grouped tree or flat list, and the workspace
|
||||||
* dialogs. Wide state renders the full browser; rail state renders the two
|
* dialogs. Wide state renders the full browser; rail state renders the two
|
||||||
* region icons (search / add workspace), each requesting shell expansion
|
* region icons (search / add workspace), each requesting shell expansion
|
||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
||||||
import type { SessionNode } from './tree.ts'
|
import type { SessionNode, SessionOrderBy } from './tree.ts'
|
||||||
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
||||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||||
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
|
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
|
||||||
@@ -34,6 +34,8 @@ const EXPAND_SLIDE_MS = 300
|
|||||||
const SEARCH_DEBOUNCE_MS = 250
|
const SEARCH_DEBOUNCE_MS = 250
|
||||||
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
|
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
|
||||||
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
||||||
|
/** Session rows visible per Workspace before the local overflow control. */
|
||||||
|
const COLLAPSED_SESSION_LIMIT = 6
|
||||||
|
|
||||||
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
||||||
function sanitizeSearchQuery(value: string): string {
|
function sanitizeSearchQuery(value: string): string {
|
||||||
@@ -51,10 +53,12 @@ function toggled(list: readonly string[], key: string): string[] {
|
|||||||
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
|
||||||
function GroupByMenu({ groupBy, onPick, t }: {
|
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
||||||
groupBy: 'workspace' | 'flat'
|
groupBy: 'workspace' | 'flat'
|
||||||
onPick: (mode: 'workspace' | 'flat') => void
|
orderBy: SessionOrderBy
|
||||||
|
onGroupPick: (mode: 'workspace' | 'flat') => void
|
||||||
|
onOrderPick: (mode: SessionOrderBy) => void
|
||||||
t: WorkspaceBrowserProps['t']
|
t: WorkspaceBrowserProps['t']
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -66,14 +70,19 @@ function GroupByMenu({ groupBy, onPick, t }: {
|
|||||||
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
||||||
{ id: 'workspace', label: t('groupBy.workspace') },
|
{ id: 'workspace', label: t('groupBy.workspace') },
|
||||||
{ id: 'flat', label: t('groupBy.flat') },
|
{ id: 'flat', label: t('groupBy.flat') },
|
||||||
|
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
|
||||||
|
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
|
||||||
|
{ id: 'created', label: t('orderBy.created') },
|
||||||
|
{ id: 'updated', label: t('orderBy.updated') },
|
||||||
]}
|
]}
|
||||||
selectedId={groupBy}
|
selectedIds={[groupBy, orderBy]}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
|
if (id === 'workspace' || id === 'flat') onGroupPick(id)
|
||||||
if (id === 'workspace' || id === 'flat') onPick(id)
|
else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
align="end"
|
align="end"
|
||||||
|
dense
|
||||||
// Portal: the section header clips overflow, so an in-place list would
|
// Portal: the section header clips overflow, so an in-place list would
|
||||||
// be cut off at the header's bounds.
|
// be cut off at the header's bounds.
|
||||||
portal
|
portal
|
||||||
@@ -116,16 +125,19 @@ type SessionTreeProps = Pick<
|
|||||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||||
/** Archive a session (row menu action; the row disappears on the state echo). */
|
/** Archive a session (row menu action; the row disappears on the state echo). */
|
||||||
onSessionArchive: (sessionId: SessionNode['id']) => void
|
onSessionArchive: (sessionId: SessionNode['id']) => void
|
||||||
|
/** Visual order; only manual mode exposes durable Workspace dragging. */
|
||||||
|
orderBy: SessionOrderBy
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||||
function SessionTree({
|
function SessionTree({
|
||||||
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
||||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
|
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
const list = useSessions(s => s)
|
const list = useSessions(s => s)
|
||||||
const current = list.current
|
const current = list.current
|
||||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||||
|
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
|
||||||
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
||||||
const [drag, setDrag] = useState<DragState | null>(null)
|
const [drag, setDrag] = useState<DragState | null>(null)
|
||||||
const currentGroup = current === undefined
|
const currentGroup = current === undefined
|
||||||
@@ -137,8 +149,8 @@ function SessionTree({
|
|||||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||||
}, [current, currentGroup])
|
}, [current, currentGroup])
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
|
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy),
|
||||||
[list, workspaces, archivedSessionIds, expandedProjects],
|
[list, workspaces, archivedSessionIds, expandedProjects, orderBy],
|
||||||
)
|
)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
@@ -173,11 +185,14 @@ function SessionTree({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{group.sessions.map((node, index) => {
|
{(expandedSessionGroups.includes(group.key)
|
||||||
|
? group.sessions
|
||||||
|
: group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
|
||||||
|
).map((node, index) => {
|
||||||
// Draggable: real-workspace session rows. The drag
|
// Draggable: real-workspace session rows. The drag
|
||||||
// never leaves its group — rows of other groups show no markers
|
// never leaves its group — rows of other groups show no markers
|
||||||
// and reject drops (visual movement confined to this section).
|
// and reject drops (visual movement confined to this section).
|
||||||
const draggable = group.workspaceId !== undefined
|
const draggable = group.workspaceId !== undefined && orderBy === 'manual'
|
||||||
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
||||||
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
||||||
start: () => {
|
start: () => {
|
||||||
@@ -223,6 +238,18 @@ function SessionTree({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{group.sessions.length > COLLAPSED_SESSION_LIMIT && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.sessionOverflowButton}
|
||||||
|
aria-expanded={expandedSessionGroups.includes(group.key)}
|
||||||
|
onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
|
||||||
|
>
|
||||||
|
{expandedSessionGroups.includes(group.key)
|
||||||
|
? t('sessions.collapse')
|
||||||
|
: t('sessions.expand', { n: group.sessions.length - COLLAPSED_SESSION_LIMIT })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -232,11 +259,14 @@ function SessionTree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||||
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
|
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick<
|
||||||
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
|
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't'
|
||||||
>) {
|
>) {
|
||||||
const list = useSessions(s => s)
|
const list = useSessions(s => s)
|
||||||
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
|
const rows = useMemo(
|
||||||
|
() => deriveFlat(list, archivedSessionIds, orderBy),
|
||||||
|
[list, archivedSessionIds, orderBy],
|
||||||
|
)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
return (
|
return (
|
||||||
<div className={clsx(css.treeBody, css.wide)}>
|
<div className={clsx(css.treeBody, css.wide)}>
|
||||||
@@ -367,6 +397,12 @@ export function WorkspaceBrowser({
|
|||||||
// flow reads): a composition without a picking affordance can add nothing.
|
// flow reads): a composition without a picking affordance can add nothing.
|
||||||
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
|
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
|
||||||
const groupBy = useStore(s => s.groupBy)
|
const groupBy = useStore(s => s.groupBy)
|
||||||
|
// A live HMR handoff can retain the pre-ordering store instance until the
|
||||||
|
// slot is remounted; manual is the established Workspace order.
|
||||||
|
const orderBy = useStore(s => s.orderBy ?? 'manual')
|
||||||
|
// A flat list has no single Workspace account to drag. Keep the stored
|
||||||
|
// grouped preference intact while presenting the flat list by recency.
|
||||||
|
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
|
||||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||||
// does not silently drop an in-progress filter.
|
// does not silently drop an in-progress filter.
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -548,7 +584,15 @@ export function WorkspaceBrowser({
|
|||||||
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
|
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
|
{wide && (
|
||||||
|
<ViewOptionsMenu
|
||||||
|
groupBy={groupBy}
|
||||||
|
orderBy={effectiveOrderBy}
|
||||||
|
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
|
||||||
|
onOrderPick={(mode) => { actions.setOrderBy(mode) }}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Adding is the button's one action, so a composition with no
|
{/* Adding is the button's one action, so a composition with no
|
||||||
picking affordance has nothing to offer here: the region hides the
|
picking affordance has nothing to offer here: the region hides the
|
||||||
button rather than leaving a dead one in the header. */}
|
button rather than leaving a dead one in the header. */}
|
||||||
@@ -644,7 +688,7 @@ export function WorkspaceBrowser({
|
|||||||
<FlatList
|
<FlatList
|
||||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||||
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
||||||
archivedSessionIds={archivedSessionIds} t={t}
|
archivedSessionIds={archivedSessionIds} orderBy={effectiveOrderBy} t={t}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
@@ -658,6 +702,7 @@ export function WorkspaceBrowser({
|
|||||||
startSession={startSession}
|
startSession={startSession}
|
||||||
open={open}
|
open={open}
|
||||||
insertSessionBefore={insertSessionBefore}
|
insertSessionBefore={insertSessionBefore}
|
||||||
|
orderBy={orderBy}
|
||||||
t={t}
|
t={t}
|
||||||
onRenameRequest={(workspaceId, currentTitle) => {
|
onRenameRequest={(workspaceId, currentTitle) => {
|
||||||
setRenameTarget({ workspaceId, currentTitle })
|
setRenameTarget({ workspaceId, currentTitle })
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export const zh = {
|
|||||||
'groupBy.label': '分组方式',
|
'groupBy.label': '分组方式',
|
||||||
'groupBy.workspace': '按工作区',
|
'groupBy.workspace': '按工作区',
|
||||||
'groupBy.flat': '单列表',
|
'groupBy.flat': '单列表',
|
||||||
|
'orderBy.label': '排序方式',
|
||||||
|
'orderBy.manual': '手动排序',
|
||||||
|
'orderBy.created': '创建时间',
|
||||||
|
'orderBy.updated': '最近更新',
|
||||||
|
'sessions.expand': '展开其余 {n} 个会话',
|
||||||
|
'sessions.collapse': '收起',
|
||||||
'empty.none': '暂无会话',
|
'empty.none': '暂无会话',
|
||||||
'empty.noMatches': '无匹配结果',
|
'empty.noMatches': '无匹配结果',
|
||||||
'workspace.add': '添加工作区',
|
'workspace.add': '添加工作区',
|
||||||
@@ -76,6 +82,12 @@ export const en = {
|
|||||||
'groupBy.label': 'Group by',
|
'groupBy.label': 'Group by',
|
||||||
'groupBy.workspace': 'WorkSpace',
|
'groupBy.workspace': 'WorkSpace',
|
||||||
'groupBy.flat': 'In one list',
|
'groupBy.flat': 'In one list',
|
||||||
|
'orderBy.label': 'Order by',
|
||||||
|
'orderBy.manual': 'Manual',
|
||||||
|
'orderBy.created': 'Date created',
|
||||||
|
'orderBy.updated': 'Last updated',
|
||||||
|
'sessions.expand': 'Show {n} more sessions',
|
||||||
|
'sessions.collapse': 'Show less',
|
||||||
'empty.none': 'No sessions yet',
|
'empty.none': 'No sessions yet',
|
||||||
'empty.noMatches': 'No matches',
|
'empty.noMatches': 'No matches',
|
||||||
'workspace.add': 'Add workspace',
|
'workspace.add': 'Add workspace',
|
||||||
|
|||||||
@@ -82,14 +82,10 @@
|
|||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
/* Compact one-line Workspace row after removing the session-count subtitle. */
|
||||||
actions all top-align on the 20px first text line (figma cell) — content
|
|
||||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
|
||||||
.projectRow {
|
.projectRow {
|
||||||
height: 54px;
|
height: 36px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project (workspace) header row: 54px, folder + title + session count;
|
* Project (workspace) header row: folder + title;
|
||||||
* hover reveals the chevron and create button, and dwelling on a real
|
* hover reveals the chevron and create button, and dwelling on a real
|
||||||
* Workspace shows its hover card (the ungrouped bucket has none).
|
* Workspace shows its hover card (the ungrouped bucket has none).
|
||||||
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
|
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
|
||||||
@@ -89,7 +89,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
|
|||||||
// The ungrouped bucket has no workspace title: its label is dictionary copy.
|
// The ungrouped bucket has no workspace title: its label is dictionary copy.
|
||||||
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
|
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
|
||||||
const active = group.expanded && group.containsCurrent
|
const active = group.expanded && group.containsCurrent
|
||||||
const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount })
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const workspaceMenuItems = [
|
const workspaceMenuItems = [
|
||||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
|
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
|
||||||
@@ -110,7 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
|
|||||||
</span>
|
</span>
|
||||||
<span className={css.projectText}>
|
<span className={css.projectText}>
|
||||||
<span className={css.title}>{label}</span>
|
<span className={css.title}>{label}</span>
|
||||||
<span className={css.meta}>{count}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={css.rowActions}>
|
<span className={css.rowActions}>
|
||||||
{actions !== undefined && (
|
{actions !== undefined && (
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run
|
|||||||
|
|
||||||
/** Session-list grouping mode: workspace sections or one flat recency list. */
|
/** Session-list grouping mode: workspace sections or one flat recency list. */
|
||||||
export type WorkspaceGroupBy = 'workspace' | 'flat'
|
export type WorkspaceGroupBy = 'workspace' | 'flat'
|
||||||
|
/** Session order: durable Workspace order or a derived timestamp order. */
|
||||||
|
export type WorkspaceOrderBy = 'manual' | 'created' | 'updated'
|
||||||
|
|
||||||
/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
|
/** Workspace browser viewing state; transient expansion facts stay component-local. */
|
||||||
type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
|
type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Annotation twin of the actions literal below (the export needs a declared
|
* Annotation twin of the actions literal below (the export needs a declared
|
||||||
@@ -19,6 +21,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
|
|||||||
*/
|
*/
|
||||||
type WorkspaceViewActions = {
|
type WorkspaceViewActions = {
|
||||||
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
||||||
|
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,10 +30,12 @@ type WorkspaceViewActions = {
|
|||||||
*/
|
*/
|
||||||
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
||||||
return defineStore({
|
return defineStore({
|
||||||
init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
|
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }),
|
||||||
persist: 'dsh.workspace.view',
|
// The added order field changes the whole-value persistence format.
|
||||||
|
persist: 'dsh.workspace.view.v2',
|
||||||
actions: {
|
actions: {
|
||||||
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
||||||
|
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ export interface SessionNode {
|
|||||||
runningSubagentCount: number
|
runningSubagentCount: number
|
||||||
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
|
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
|
||||||
completed: boolean
|
completed: boolean
|
||||||
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Session order selected by the Workspace browser. */
|
||||||
|
export type SessionOrderBy = 'manual' | 'created' | 'updated'
|
||||||
|
|
||||||
/** One workspace group section: header row facts + visible top-level session rows. */
|
/** One workspace group section: header row facts + visible top-level session rows. */
|
||||||
export interface GroupNode {
|
export interface GroupNode {
|
||||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||||
@@ -104,6 +108,16 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
|
|||||||
return a.id < b.id ? -1 : 1
|
return a.id < b.id ? -1 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Newest-created first, id as the deterministic tiebreak. */
|
||||||
|
function byCreation(a: SessionSummary, b: SessionSummary): number {
|
||||||
|
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt
|
||||||
|
return a.id < b.id ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSessions(sessions: SessionSummary[], orderBy: Exclude<SessionOrderBy, 'manual'>): void {
|
||||||
|
sessions.sort(orderBy === 'created' ? byCreation : byRecency)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordinary sessions are visible; among blank sessions, only the current one
|
* Ordinary sessions are visible; among blank sessions, only the current one
|
||||||
* is visible. Subagent children use their parent header catalog; archived
|
* is visible. Subagent children use their parent header catalog; archived
|
||||||
@@ -133,12 +147,10 @@ function buildGroup(
|
|||||||
createdAt: number | undefined,
|
createdAt: number | undefined,
|
||||||
label: string,
|
label: string,
|
||||||
members: readonly SessionSummary[],
|
members: readonly SessionSummary[],
|
||||||
order: 'account' | 'recency',
|
orderBy: SessionOrderBy,
|
||||||
): Group {
|
): Group {
|
||||||
const sessions = [...members]
|
const sessions = [...members]
|
||||||
// Workspace order is workspace.sessionIds; only Ungrouped lacks an account
|
if (orderBy !== 'manual') sortSessions(sessions, orderBy)
|
||||||
// order and therefore falls back to recency.
|
|
||||||
if (order === 'recency') sessions.sort(byRecency)
|
|
||||||
return { key, workspaceId, cwd, createdAt, label, sessions }
|
return { key, workspaceId, cwd, createdAt, label, sessions }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +163,7 @@ function groupByWorkspace(
|
|||||||
list: SessionListState,
|
list: SessionListState,
|
||||||
workspaces: readonly WorkspaceView[],
|
workspaces: readonly WorkspaceView[],
|
||||||
archived: ReadonlySet<SessionId>,
|
archived: ReadonlySet<SessionId>,
|
||||||
|
orderBy: SessionOrderBy,
|
||||||
): Group[] {
|
): Group[] {
|
||||||
const groups: Group[] = []
|
const groups: Group[] = []
|
||||||
const accounted = new Set<SessionId>()
|
const accounted = new Set<SessionId>()
|
||||||
@@ -165,7 +178,7 @@ function groupByWorkspace(
|
|||||||
}
|
}
|
||||||
groups.push(buildGroup(
|
groups.push(buildGroup(
|
||||||
workspace.workspaceId, workspace.workspaceId, workspace.path,
|
workspace.workspaceId, workspace.workspaceId, workspace.path,
|
||||||
Date.parse(workspace.createdAt), workspace.title, members, 'account',
|
Date.parse(workspace.createdAt), workspace.title, members, orderBy,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
const stray = list.ids
|
const stray = list.ids
|
||||||
@@ -173,7 +186,10 @@ function groupByWorkspace(
|
|||||||
.filter((s): s is SessionSummary =>
|
.filter((s): s is SessionSummary =>
|
||||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
|
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
|
||||||
if (stray.length > 0) {
|
if (stray.length > 0) {
|
||||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
groups.push(buildGroup(
|
||||||
|
UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray,
|
||||||
|
orderBy === 'manual' ? 'updated' : orderBy,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
@@ -189,6 +205,7 @@ function sessionNode(
|
|||||||
running: s.running,
|
running: s.running,
|
||||||
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
|
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
|
||||||
completed: s.completed === true,
|
completed: s.completed === true,
|
||||||
|
createdAt: s.createdAt,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
|
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
|
||||||
}
|
}
|
||||||
@@ -213,6 +230,7 @@ export function deriveGroups(
|
|||||||
workspaces: readonly WorkspaceView[],
|
workspaces: readonly WorkspaceView[],
|
||||||
archivedSessionIds: readonly SessionId[],
|
archivedSessionIds: readonly SessionId[],
|
||||||
view: TreeView,
|
view: TreeView,
|
||||||
|
orderBy: SessionOrderBy = 'manual',
|
||||||
): GroupNode[] {
|
): GroupNode[] {
|
||||||
const archived = new Set(archivedSessionIds)
|
const archived = new Set(archivedSessionIds)
|
||||||
const expandedProjects = new Set(view.expandedProjects)
|
const expandedProjects = new Set(view.expandedProjects)
|
||||||
@@ -222,7 +240,7 @@ export function deriveGroups(
|
|||||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||||
?? UNGROUPED_KEY
|
?? UNGROUPED_KEY
|
||||||
const groups: GroupNode[] = []
|
const groups: GroupNode[] = []
|
||||||
for (const g of groupByWorkspace(list, workspaces, archived)) {
|
for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) {
|
||||||
const expanded = expandedProjects.has(g.key)
|
const expanded = expandedProjects.has(g.key)
|
||||||
groups.push({
|
groups.push({
|
||||||
key: g.key,
|
key: g.key,
|
||||||
@@ -248,7 +266,11 @@ export function deriveGroups(
|
|||||||
* @param archivedSessionIds - registry-global archive set.
|
* @param archivedSessionIds - registry-global archive set.
|
||||||
* @returns flat rows in render order.
|
* @returns flat rows in render order.
|
||||||
*/
|
*/
|
||||||
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
|
export function deriveFlat(
|
||||||
|
list: SessionListState,
|
||||||
|
archivedSessionIds: readonly SessionId[],
|
||||||
|
orderBy: SessionOrderBy = 'updated',
|
||||||
|
): SessionNode[] {
|
||||||
const archived = new Set(archivedSessionIds)
|
const archived = new Set(archivedSessionIds)
|
||||||
const descendants = indexSubagentDescendants(list.byId)
|
const descendants = indexSubagentDescendants(list.byId)
|
||||||
const rows: SessionSummary[] = []
|
const rows: SessionSummary[] = []
|
||||||
@@ -257,7 +279,7 @@ export function deriveFlat(list: SessionListState, archivedSessionIds: readonly
|
|||||||
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
|
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
|
||||||
rows.push(s)
|
rows.push(s)
|
||||||
}
|
}
|
||||||
rows.sort(byRecency)
|
sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy)
|
||||||
return rows.map(session => sessionNode(session, descendants))
|
return rows.map(session => sessionNode(session, descendants))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[
|
|||||||
function summarize(session: Session, running: boolean): SessionSummary {
|
function summarize(session: Session, running: boolean): SessionSummary {
|
||||||
return {
|
return {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
|
createdAt: session.header.createdAt,
|
||||||
// Excludes end-seed: a resumed-but-untouched session
|
// Excludes end-seed: a resumed-but-untouched session
|
||||||
// must not sort as freshly worked in.
|
// must not sort as freshly worked in.
|
||||||
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
||||||
@@ -499,6 +500,7 @@ async function summarizeCold(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sessionId: meta.id,
|
sessionId: meta.id,
|
||||||
|
createdAt: meta.createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
running: false,
|
running: false,
|
||||||
// Lazy persistence keeps never-appended sessions out of list(); reading
|
// Lazy persistence keeps never-appended sessions out of list(); reading
|
||||||
@@ -3377,6 +3379,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
queue.push(frame({
|
queue.push(frame({
|
||||||
type: 'host/session-added',
|
type: 'host/session-added',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
|
createdAt: session.header.createdAt,
|
||||||
// Derived at frame time like summarize(); a just-created session
|
// Derived at frame time like summarize(); a just-created session
|
||||||
// has run no turn yet, so this is constantly true in practice.
|
// has run no turn yet, so this is constantly true in practice.
|
||||||
blank: sessionBlank(session),
|
blank: sessionBlank(session),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
|||||||
z.object({
|
z.object({
|
||||||
type: z.literal('host/session-added'),
|
type: z.literal('host/session-added'),
|
||||||
sessionId: sessionIdSchema,
|
sessionId: sessionIdSchema,
|
||||||
|
createdAt: z.number().optional(),
|
||||||
blank: z.boolean(),
|
blank: z.boolean(),
|
||||||
parentSessionId: sessionIdSchema.optional(),
|
parentSessionId: sessionIdSchema.optional(),
|
||||||
origin: z.literal('subagent').optional(),
|
origin: z.literal('subagent').optional(),
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export type HostFrame =
|
|||||||
| {
|
| {
|
||||||
type: 'host/session-added'
|
type: 'host/session-added'
|
||||||
sessionId: SessionId
|
sessionId: SessionId
|
||||||
|
createdAt?: number
|
||||||
blank: boolean
|
blank: boolean
|
||||||
parentSessionId?: SessionId
|
parentSessionId?: SessionId
|
||||||
origin?: 'subagent'
|
origin?: 'subagent'
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const sessionEventSchema = z.object({
|
|||||||
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
||||||
export const sessionSummarySchema = z.object({
|
export const sessionSummarySchema = z.object({
|
||||||
sessionId: sessionIdSchema,
|
sessionId: sessionIdSchema,
|
||||||
|
createdAt: z.number().optional(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
running: z.boolean(),
|
running: z.boolean(),
|
||||||
blank: z.boolean(),
|
blank: z.boolean(),
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ export type QueueAction =
|
|||||||
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
sessionId: SessionId
|
sessionId: SessionId
|
||||||
|
/** Session creation time from the durable session header when supplied by the Host. */
|
||||||
|
createdAt?: number
|
||||||
/**
|
/**
|
||||||
* Last activity. Attached: the last non-`session/end-seed` event, since a
|
* Last activity. Attached: the last non-`session/end-seed` event, since a
|
||||||
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
|
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
|
||||||
|
|||||||
Reference in New Issue
Block a user