feat: pick the focus and the allowed paths from a directory listing
Release / release (push) Successful in 6s

Both scope controls were free-text boxes, and the vocabulary they asked the user
for is paths relative to the session folder. "Which folder is `../shared`" is the
one question a text box cannot answer, so the panel now shows the Host's own
directory: the `uiWorkspace.listDirectory` primitive the workspace picker's
browser drives, one level of child folders, a breadcrumb trail, and a
hidden-entries toggle. The path field stays editable inside the picker, because
the Host lists folders and an extra may legitimately name a file.

Picks are stored in the form that keeps their meaning. A folder at
`/work/root/src/shared` is declared `src/shared` when the session started in
`/work/root`, and `../shared` going up; only a path with nothing shared — another
drive, another tree — stays absolute. The Focus row shows that declared form,
with the resolved path in its title, which is what the agent will see.

`Set` is gone with the box it committed: the panel writes on pick, as every other
control here already did. `Clear` stays, because empty means the session folder —
the schema's own default rather than an override naming the same place.

The card was 380px wide and grew without bound, a single column of prose taller
than the viewport. It is 760px now, with a two-column grid that `auto-fit`
collapses to one on a narrow card, the reference prose folded into `<details>`,
and a scroll-bounded body. `Modal` sizes its own card from a hashed class in the
primitives' stylesheet, so this bundle installs one small stylesheet of its own
and outranks that rule with a doubled class selector — no `!important`, and
nothing reaching into a class name it does not own.

Two fixes came with it. The footer's "Deployment mode:" line read a slot prop
that never carried the mode, so it said "ask" in every deployment; it reads the
settings document now. And `uiWorkspace` is resolved with `ctx.get` at the call
rather than injected, because the panel is still the only way to declare scope in
a deployment with no directory browser: it keeps the panel and says what it
cannot do, instead of losing the button.
This commit is contained in:
NotExpectedYet committed 2026-09-27 07:05:47 +01:00
1 parent ef3df2d939
commit 5d06829118
4 files changed
+1046 -140

No files matched your search

+278 -20
View File
@@ -35,6 +35,9 @@ async function loadClient() {
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
// The listing's row glyph; a client half may only use what the primitives
// actually export, so the stand-in is named the same way.
IconFolderCloseRegular: 'IconFolderCloseRegular',
/** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class {
constructor(scope, specs) {
@@ -84,14 +87,21 @@ async function loadClient() {
}
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) {
function drive(mod, { served = true, value = {}, workspace } = {}) {
const seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = []
let currentSlot
// The optional service the context panel browses through. `get` is the
// Cordis read that answers `undefined` rather than throwing, which is what
// the plugin relies on to keep the panel in a deployment with no browser.
const get = (name) => (name === 'uiWorkspace' ? workspace : undefined)
const ctx = {
get,
inject: (names, scope) => {
const scoped = {
get,
configForms: {
get: (ns) => {
seen.formNs = ns
@@ -170,8 +180,10 @@ test('the context button rides the composer tool row, gated on the same namespac
assert.equal(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the
// very same document the plugin card does.
// very same document the plugin card does — and the browse face, because the
// panel's two controls are directory listings.
assert.equal(typeof button.options.inject().form, 'object')
assert.equal(typeof button.options.inject().browse.list, 'function')
})
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
@@ -291,6 +303,10 @@ test('the client half loads the packages whose slots it registers into', async (
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
// Not a slot this half registers into: the panel's directory listing drives
// `uiWorkspace.listDirectory`, so the package that provides it has to be in
// the boot graph before this half asks for it.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-workspace'), 'browses through uiWorkspace')
})
// ── the context button and panel ─────────────────────────────────────────────
@@ -364,31 +380,32 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
mutate: (ops) => (written.push(ops), true),
}
const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} })
const tree = mod.FacetContextPanel({
form,
sessionId: 'session-1',
cwd: '/work/root',
browse: { available: () => true, list: () => Promise.resolve() },
onClose: () => {},
})
assert.equal(tree.type, 'Modal')
// The class is the whole width fix: the primitive's own card is 380px and it
// takes only a class name, so the stylesheet this bundle installs outranks it.
assert.equal(tree.props.className, mod.PANEL_CLASS)
// Walk the tree for the text and controls a user would see.
const texts = []
const buttons = []
const texts = textsOf(tree)
const buttons = buttonsOf(tree)
const inputs = []
const walk = (node) => {
if (typeof node === 'string') return texts.push(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) return node.forEach(walk)
if (node.type === 'button') buttons.push(node)
walkTree(tree, (node) => {
if (node.type === 'input') inputs.push(node)
// Children arrive either as createElement arguments or, as the card does it,
// through the `children` prop.
if (Array.isArray(node.children)) node.children.forEach(walk)
walk(node.props?.children)
}
walk(tree)
})
const labelOf = (node) => (node.children ?? [])[0]
const prose = texts.join('\n')
assert.match(prose, /What this conversation may see/)
// The two tabs the preset picker cannot show for a custom preset live here.
// The two tabs the preset picker cannot show for a custom preset live here,
// folded away so the controls are not buried under them.
assert.match(prose, /Mode details/)
assert.match(prose, /How to use/)
// The honest bit about this deployment, so the panel does not promise a
@@ -397,9 +414,250 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
assert.match(prose, /danger-full-access/)
// The declared scope is on screen, and the existing extra has its own control.
assert.equal(inputs[0].props.value, 'src/room')
assert.ok(texts.includes('src/room'), 'the focus is shown as the declared path')
assert.ok(texts.some((text) => text.includes('/work/root')), 'the session folder relative to what is named')
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
assert.ok(buttons.some((button) => labelOf(button) === 'Add'))
// Both controls are the listing. `Set` is gone with the box it committed:
// picking writes the declaration straight away, as every other control here
// already did.
assert.ok(buttons.some((button) => labelOf(button) === 'Browse\u2026'))
assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
assert.ok(buttons.some((button) => labelOf(button) === 'Set'))
assert.ok(buttons.some((button) => labelOf(button) === '+ Add folder or path\u2026'))
assert.ok(!buttons.some((button) => labelOf(button) === 'Set'))
assert.deepEqual(inputs, [], 'the panel itself has no free-text box left')
})
test('a deployment with no directory browser keeps the panel and says so', async () => {
const { module: mod } = await loadClient()
const form = {
getSnapshot: () => ({ status: 'ready', writable: true, revision: 1, value: { mode: 'deny', scopes: {} } }),
subscribe: () => () => {},
mutate: () => true,
}
const tree = mod.FacetContextPanel({
form,
sessionId: 's',
cwd: '',
browse: mod.directoryBrowse({ get: () => undefined }),
onClose: () => {},
})
const texts = textsOf(tree)
const label = (button) => (button.children ?? [])[0]
// The panel is the only way to declare scope, so it renders without a
// browser: the listing affordances go dead, the explanation stays.
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === 'Browse\u2026')
.map((button) => button.props.disabled),
[true],
)
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === '+ Add folder or path\u2026')
.map((button) => button.props.disabled),
[true],
)
assert.ok(texts.some((text) => text.includes('no directory browser')))
// The mode is read from the document rather than from a slot prop that never
// carried it, so the line does not claim "ask" whatever the setting is.
assert.ok(texts.some((text) => text.includes('Deployment mode: deny')))
})
// ── the directory listing ────────────────────────────────────────────────────
/** A listing shaped the way the Host's browse primitive answers. */
function listingFixture() {
return {
path: '/work/root/src',
home: '/home/dev',
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'work', path: '/work', hidden: false },
{ name: 'root', path: '/work/root', hidden: false },
{ name: 'src', path: '/work/root/src', hidden: false },
],
entries: [
{ name: 'shared', path: '/work/root/src/shared', hidden: false },
{ name: '.git', path: '/work/root/src/.git', hidden: true },
],
truncated: false,
}
}
/** Let the model's own promise chain settle. */
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0))
}
/** Every button in a rendered tree, in document order. */
function buttonsOf(tree) {
const found = []
walkTree(tree, (node) => {
if (node.type === 'button') found.push(node)
})
return found
}
/** Every string in a rendered tree, in document order. */
function textsOf(tree) {
const found = []
walkTree(tree, (node) => {
if (typeof node === 'string') found.push(node)
})
return found
}
/** Walk a rendered tree the way it was built: children as arguments or as a prop. */
function walkTree(node, visit) {
if (typeof node === 'string') return visit(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) walkTree(child, visit)
return
}
visit(node)
if (Array.isArray(node.children)) node.children.forEach((child) => walkTree(child, visit))
walkTree(node.props?.children, visit)
}
test('a path is declared relative to the session folder wherever it can be', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.relativeTo('/work/root', '/work/root'), '', 'the session folder itself has no name of its own')
assert.equal(mod.relativeTo('/work/root', '/work/root/src/shared'), 'src/shared')
assert.equal(mod.relativeTo('/work/root/dsh-facet', '/work/root'), '..')
assert.equal(mod.relativeTo('/work/root/dsh-facet/lib', '/work/root'), '../..')
// Nothing shared: another tree, or another drive. Absolute is the only
// spelling that still names it.
assert.equal(mod.relativeTo('/work/root', '/srv/other'), '/srv/other')
assert.equal(mod.relativeTo('C:\\work\\root', 'D:\\work\\root'), 'D:\\work\\root')
assert.equal(mod.relativeTo('', '/srv/other'), '/srv/other')
// Windows separators split and join like POSIX ones.
assert.equal(mod.relativeTo('C:\\work\\root', 'C:\\work\\other'), '../other')
assert.equal(mod.absoluteFrom('/work/root', 'src/shared'), '/work/root/src/shared')
assert.equal(mod.absoluteFrom('/work/root', '/srv/other'), '/srv/other')
assert.equal(mod.absoluteFrom('/work/root', ''), '', 'empty stays empty: it means the session folder')
assert.equal(mod.absoluteFrom('', 'src/shared'), 'src/shared', 'with no folder to join, a relative path is left alone')
assert.equal(mod.isAbsolutePath('C:\\x'), true)
assert.equal(mod.isAbsolutePath('\\\\server\\share'), true)
assert.equal(mod.isAbsolutePath('src/x'), false)
})
test('the browse face is read from uiWorkspace at the call, not at registration', async () => {
const { module: mod } = await loadClient()
// Absent service: the probe answers false and the call rejects with something
// the picker can show rather than a TypeError from an undefined property.
const missing = mod.directoryBrowse({ get: () => undefined })
assert.equal(missing.available(), false)
await assert.rejects(missing.list('/tmp'), /no directory browser/)
const calls = []
const present = mod.directoryBrowse({
get: () => ({
listDirectory: (path, signal) => {
calls.push([path, signal])
return Promise.resolve('listing')
},
}),
})
assert.equal(present.available(), true)
assert.equal(await present.list('/tmp', 'signal'), 'listing')
assert.deepEqual(calls, [['/tmp', 'signal']])
})
test('the picker model lists a level, supersedes a stale one, and keeps a typed path on failure', async () => {
const { module: mod } = await loadClient()
const asked = []
const model = new mod.DirectoryPickerModel({
list: (path) => {
asked.push(path)
return Promise.resolve(listingFixture())
},
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.deepEqual(asked, ['/work/root/src'], 'the opening level is listed, not guessed at')
assert.equal(model.getSnapshot().listing.path, '/work/root/src')
assert.equal(model.getSnapshot().draft, '/work/root/src', 'the field follows a successful listing')
assert.equal(model.getSnapshot().loading, false)
// A second navigation wins even when the first is still in flight: the
// sequence number is what stops an early listing landing on a later view.
model.open('/work/root/other')
model.setDraft('notes/CONTROLLER.md')
// A failure keeps the level on screen and the path as typed: the Host lists
// folders, and the extras legitimately name a file.
const failing = new mod.DirectoryPickerModel({
list: () => Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })),
root: '/work/root',
start: 'notes/CONTROLLER.md',
})
await settle()
assert.equal(failing.getSnapshot().error, 'directory-unreadable', 'the Host business message is preferred')
assert.equal(failing.getSnapshot().draft, 'notes/CONTROLLER.md')
})
test('the picker lists folders, hides hidden ones until asked, and answers in declared form', async () => {
const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
list: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
await settle()
const picked = []
const render = () =>
mod.DirectoryPicker({
model,
root: '/work/root',
target: 'focus',
disabled: false,
onClose: () => {},
onPick: (path) => picked.push(path),
})
const tree = render()
assert.equal(tree.type, 'Modal')
assert.equal(tree.props.className, mod.PICKER_CLASS)
assert.equal(tree.props.title, 'Choose the focus folder')
// The level is the rows, one per listed folder; the trail above them is the
// ancestry, and a hidden entry stays hidden until the toggle asks for it.
const rowTitles = (node) =>
buttonsOf(node)
.filter((button) => button.props.className === mod.ROW_CLASS)
.map((button) => button.props.title)
assert.deepEqual(rowTitles(tree), ['/work/root/src/shared'])
assert.ok(
buttonsOf(tree).some((button) => button.props.title === '/work/root' && (button.children ?? [])[0] === 'root'),
'the trail offers the ancestry as jump targets',
)
// The footer's own action takes the field's value and hands it back in the
// declared form, so the workspace can move without the grant meaning
// something else.
const footer = tree.props.footer
const pickButton = footer.find((entry) => (entry.children ?? [])[0] === 'Set focus')
assert.notEqual(pickButton, undefined, 'the footer commits the picked path')
pickButton.props.onClick()
assert.deepEqual(picked, ['src'])
model.setShowHidden(true)
assert.deepEqual(rowTitles(render()), ['/work/root/src/shared', '/work/root/src/.git'])
// A row is a way down, not a pick: standing in the folder is what chooses it.
model.open('/work/root/other')
await settle()
})