Skip to content

feat(projects): start a task at a workspace root and resolve the child repo from the first message - #17

Merged
devswha merged 5 commits into
devswha:mainfrom
lee98www:feat/workspace-quick-task
Sep 4, 2026
Merged

feat(projects): start a task at a workspace root and resolve the child repo from the first message#17
devswha merged 5 commits into
devswha:mainfrom
lee98www:feat/workspace-quick-task

Conversation

@lee98www

@lee98www lee98www commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #16.

What this changes

A project that is not a git work tree but holds git repos as immediate children (~/Projects) is treated as a workspace root. Starting a task there now resolves which child repo the first message is about and creates the session under that repo.

  • GET /api/projects/:id/resolve-target?text= ranks every child repo by name — exact token 100, whole mention 80, prefix 40, then recency; the package.json name counts as a name. No model call, one readdir plus a stat per child.
  • POST /api/projects/:id/descend { path } registers the chosen child as an explicit project (or promotes a row the indexer had only discovered) and refuses anything that is not an immediate child with .git. It is the only write.
  • The composer shows a chip above the input once the top score reaches 80 (→ gajae-code-app), with a searchable picker to override or keep the root. Send descends first, then creates the session under the child.
  • session_upserted now carries the project's origin, so a repo that just became explicit shows up in the sidebar the moment its first session lands, in every viewer, without a refetch.
  • Separate commit: on a workspace root, GET /api/projects/:id/files lists the children only. The ten-level walk stat'ed every file of every repo underneath and pinned the event loop for ~37 s on a root with a hundred checkouts, starving every other request. The walk is unchanged for real repos.

No schema change; the projects list payload is untouched, so legacy rows cost nothing. Nothing changes for a project that is already a repo.

Why

Registering every repository as its own project is the main friction for anyone who keeps many repos under one root. The runtime already thinks in "start at the root, descend to the repo the message is about"; the app did not. See #16 for the shape discussion — happy to rename the endpoints, change the scoring, or move the resolution into the composer entirely if you would rather keep the server thin.

Verification

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — server 600 pass / 0 fail (node:test), client 454 / 0, scripts 16 / 0, every Bun suite 0 fail. New: server/modules/projects/tests/workspace-target.test.ts (12: scoring rules incl. the whole-mention boundary and a Korean directory name, fixture workspace with hidden / node_modules / non-git children, descend 201→200, un-archive, promotion of an auto row, rejection of traversal / non-child / non-git), src/components/chat/hooks/useWorkspaceTarget.dom.bun.test.tsx, src/components/chat/view/WorkspaceTargetChip.dom.bun.test.tsx, and an origin-promotion case in useProjectsState.query.dom.bun.test.tsx.
  • npm run check:identity — passed; npm run build:client — built.
  • scripts/check-locale-parity.test.mjs — the new workspaceTarget.* keys are in all ten locales.
  • Live, against a real ~/Projects with 99 child repos through the dev server and a headless Chromium: typing hf-studio 빌드 왜 깨지는지 봐줘 resolved the chip in ~320 ms, Send created the session with sessions.project_path = …/hf-studio, the runtime titled it, the sidebar showed hf-studio with the session without a reload; picker filter, "Keep at root", and the upward/downward flip of the popup checked; descend rejected ../, a grandchild, and a child without .git.

  • I have signed the Contributor License Agreement, or I am the project owner.
  • npm run verify passes, or I have said below which gate fails and why. — typecheck, lint, test, check:identity and build:client were run individually and pass; the full verify chain (audit, licences, notices, Rust core) is running now and I will post the result as a comment.

A project that is not a git work tree but holds git repos as immediate
children (~/Projects) is a workspace root. Starting a task there used to
run the agent at the root; now the composer resolves which child repo the
message is about and the session is created under that repo.

Server: GET /api/projects/:id/resolve-target?text= ranks every child repo
by name (exact token 100, whole mention 80, prefix 40, then recency; the
package.json name counts as a name) and POST /api/projects/:id/descend
registers the chosen child as an explicit project, refusing anything that
is not an immediate child with .git. No schema change; the projects list
payload is untouched so legacy rows cost nothing.

Client: a chip above the composer shows the resolved target once the top
score reaches 80, with a searchable picker to override or keep the root.
Send descends first, then creates the session under the child. The
session_upserted event now carries the project's origin so a repo the
indexer had only discovered shows up in the sidebar the moment its first
session lands, in every viewer, without a refetch.
…y repo

Selecting a project loads its file tree for @-mentions ten levels deep,
stat'ing every file. On a workspace root that means every file of every
repo underneath: ~/Projects with a hundred checkouts pinned the event loop
for minutes and starved every other request. A workspace root now lists
its children only; the walk is unchanged for real repos.
@lee98www

lee98www commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

npm run verify status, run gate by gate on this branch (Node 22.23.2, Bun 1.4.0):

  • check:licenses ✓ · check:notices ✓ · typecheck ✓ · check:core ✓ (fmt, clippy -D warnings, cargo test) · test ✓ (server 600 / client 454 / scripts 16 / every Bun suite, 0 failures) · lint ✓ · check:identity ✓ · build
  • audit — could not complete from here: registry.npmjs.org/-/npm/v1/security/advisories/bulk and /audits/quick time out (30 s, no response) from this network right now, so npm audit hangs before producing a report. The recorded extract-zip exception matched on main earlier today when the endpoint was reachable; nothing in this PR touches dependencies or the lockfile. CI's own audit job is the authoritative run.

- list a workspace root's own entries only (depth 0) in the file tree
- enumerate child repos once per request via a shared snapshot
- reject symlinked/oversized package.json manifests when scoring
- propagate readdir/stat failures instead of reporting a non-workspace
- whole-mention score short names the ASCII tokenizer mangles (CJK, c++)
A send inside the debounce window previously created the session at the
workspace root; allocation now awaits resolve-target with the submitted
text, honors an explicit pin (including keep-at-root), and surfaces
resolver failures instead of falling back to the root. Preview requests
are project-scoped and text-capped, pins reset on project change, and
cached project origin updates are monotonic so a stale index event can
never demote an explicit project.
@devswha

devswha commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Review (maintainer)

Solid shape — server-authoritative child validation, no model call, clean client integration. Review found five defects worth fixing before merge, so I pushed two commits on top (branch push was enabled):

d47dfd8 fix(projects): harden workspace target resolution and root file listing

  • The files endpoint used depth 1, which still opened every child repo's top level — now depth 0, so only the root's own entries are listed (the stated perf behavior was not actually implemented).
  • resolve/descend enumerated all children twice (once in isWorkspaceRoot, once in listChildRepos) — now one shared snapshot per call.
  • package.json reads were unbounded and followed symlinks — now lstat-gated regular files ≤ 64 KiB.
  • readdir failures (EACCES/EMFILE/EIO) were swallowed into isWorkspace: false, silently allowing a wrongly rooted session — now only ENOENT/ENOTDIR are treated as absence; everything else propagates.
  • Short names the ASCII tokenizer mangles (Korean directories like 前端, symbols like c++) scored 0 even when mentioned — they now whole-mention match, with the boundary check keeping short ASCII names like go from false-positiving.

66a35ae fix(chat): resolve the workspace target authoritatively at send time

  • The debounced preview was the only input to session placement: a send within 300 ms created the session at the workspace root, and a resolver failure silently fell back to root. allocate now awaits resolveForSend(submittedText) — an authoritative fetch with the exact submitted text — honors an explicit pin (including keep-at-root, no fetch), and aborts the send with a visible error on resolver failure.
  • Preview requests are project-scoped (a late response from a previous project can't repopulate state) and text is capped at 2,000 chars; pins reset on project change.
  • Cached project origin updates are now monotonic: the DB only promotes auto/legacyexplicit, so a stale watcher event can no longer demote an explicit project out of the sidebar.

Verification: workspace-target server tests 14/14, client dom tests 25/25, locale parity green, both tsconfigs typecheck clean, eslint clean on all touched files.

Deferred (non-blocking): detecting git work-tree membership for the root via git rev-parse (a registered subdir of a repo containing nested repos is still misclassified — edge case, skipped to avoid spawning git per keystroke); the chip's listbox keyboard navigation nit.

LGTM, merging.

@devswha
devswha merged commit 5d21c8c into devswha:main Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace quick task: start at a root like ~/Projects and resolve the child repo from the first message

2 participants