fix: remove orig
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
|
||||
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
|
||||
* and WorkspacePicker fills the conversation hero's picker hole
|
||||
* (`conversation.hero.workspace` — both hero forms). Both read real Host
|
||||
* Workspaces through the global useWorkspaces hook. Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type {
|
||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore registers via
|
||||
* declaration-aware deferral instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* Register the browser and picker once their slot declarations are on the
|
||||
* ledger. Inject factories return plain callbacks; data reads use the
|
||||
* framework's global hooks.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// With a workspace: materialize (reuse-or-create the blank session) and
|
||||
// navigate. Without one: clear the selection — the layout's empty seat
|
||||
// shows the New Session pure view state and the user picks there.
|
||||
startSession: (workspaceId) => {
|
||||
if (workspaceId === undefined) {
|
||||
ctx.sessions.clear()
|
||||
return
|
||||
}
|
||||
void ctx.workspaces.connectWorkspace(workspaceId).then(
|
||||
(sessionId) => { ctx.sessions.open(sessionId) },
|
||||
(reason: unknown) => { console.warn('new session failed:', reason) },
|
||||
)
|
||||
},
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
// into an undeclared slot throws. Register once the declaration is on the
|
||||
// ledger; the subscription also re-registers after an HMR collapse
|
||||
// re-declares the slot (the cascade disposed our entry with it).
|
||||
ctx.effect(() => {
|
||||
const registrations = [
|
||||
{
|
||||
name: 'sidebar.workspaces' as const,
|
||||
component: WorkspaceBrowser,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
|
||||
WorkspaceBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'conversation.hero.workspace' as const,
|
||||
component: WorkspacePicker,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'conversation.hero.workspace', inject: pickerInjected },
|
||||
WorkspacePicker,
|
||||
),
|
||||
},
|
||||
]
|
||||
const disposers = new Map<string, () => void>()
|
||||
const tryRegister = (entry: (typeof registrations)[number]): void => {
|
||||
if (ctx.slots.spec(entry.name) === undefined) return
|
||||
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
|
||||
disposers.set(entry.name, entry.register())
|
||||
}
|
||||
const unsubscribers = registrations.map(entry =>
|
||||
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
|
||||
for (const entry of registrations) tryRegister(entry)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
}
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Derives the workspace browser tree from Host Workspace order and membership.
|
||||
* Unassigned Sessions trail under Ungrouped; blank Sessions remain visible.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
||||
/** Display label for the ungrouped bucket row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** One session node of a group's visible tree (34px row; children render indented one step). */
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** Visible children, already expansion/search-filtered (empty when folded). */
|
||||
children: readonly SessionNode[]
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One workspace group section: header row facts + the visible session tree. */
|
||||
export interface GroupNode {
|
||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
/** Backing Workspace id; absent only for the ungrouped bucket. */
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
/** Total visible sessions in the group. */
|
||||
sessionCount: number
|
||||
expanded: boolean
|
||||
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
|
||||
containsCurrent: boolean
|
||||
/** Visible roots (empty while the group is folded). */
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory display label: basename of the path (both separators accepted).
|
||||
* Ungrouped-bucket fallback for surfaces without a workspace title.
|
||||
* @param cwd - directory path, or undefined for the ungrouped bucket.
|
||||
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
|
||||
*/
|
||||
export function projectLabel(cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
|
||||
function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
/** Build one group's parent/child tree from an ordered member list. */
|
||||
function buildGroup(
|
||||
key: string,
|
||||
workspaceId: WorkspaceId | undefined,
|
||||
cwd: string | undefined,
|
||||
label: string,
|
||||
members: readonly SessionSummary[],
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
}
|
||||
// Workspace order is the member iteration order (workspace.sessionIds), so
|
||||
// attached groups keep insertion order; Ungrouped sorts by recency.
|
||||
if (order === 'recency') {
|
||||
roots.sort(byRecency)
|
||||
for (const kids of children.values()) {
|
||||
kids.sort((a, b) => {
|
||||
const sa = summaries.get(a)
|
||||
const sb = summaries.get(b)
|
||||
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
|
||||
if (sa === undefined || sb === undefined) return 0
|
||||
return byRecency(sa, sb)
|
||||
})
|
||||
}
|
||||
}
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of members) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Sessions by Host Workspace: one group per entity in stable Host
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
*/
|
||||
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
for (const workspace of workspaces) {
|
||||
const members: SessionSummary[] = []
|
||||
for (const id of workspace.sessionIds) {
|
||||
const summary = list.byId[id]
|
||||
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
|
||||
accounted.add(id)
|
||||
members.push(summary)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
|
||||
))
|
||||
}
|
||||
const stray = list.ids
|
||||
.map(id => list.byId[id])
|
||||
.filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.displayTitle,
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
|
||||
return sessionNode(s, children, kids.length > 0, expanded)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!m.displayTitle.toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id) || !visible.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested workspace browser group structure.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Blank sessions are excluded everywhere.
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const expandedSessions = new Set(view.expandedSessions)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
if (q === '') {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
})
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: buildSearch(g, visible),
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat session list ("In one list" mode): every session — fork
|
||||
* children included — as a top-level row, strictly newest-first. No grouping,
|
||||
* no parent/child adjacency; rows reuse SessionNode with children always
|
||||
* empty so the renderer stays branch-free. Search mode filters by
|
||||
* case-insensitive display-title substring.
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined) continue
|
||||
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
* @param now - current epoch ms (injected for pure rendering).
|
||||
* @returns the row's trailing time label.
|
||||
*/
|
||||
export function formatRelativeTime(updatedAt: number, now: number): string {
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < MIN) return 'now'
|
||||
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
|
||||
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
|
||||
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
|
||||
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
|
||||
return `${Math.floor(diff / (365 * DAY))}y`
|
||||
}
|
||||
Reference in New Issue
Block a user