Commit Graph
25 Commits
Author SHA1 Message Date
da1671ca20 MUL-7420: Project workflows — columns and handoffs on the workflow (#8814)
* feat(workflows): workspace workflows with step handoffs (MUL-7420)

A workflow is a named, workspace-level selection of statuses from the
shared status catalog, in board order, with an optional handoff on each
step. Projects opt in through project.workflow_id; a project without one
keeps the whole catalog (the implicit Default workflow), so existing
workspaces see no change.

- issue_workflow table + project.workflow_id, CRUD API under
  /api/issue-workflows, POST /api/projects/{id}/workflow with a dry-run
  status mapping plan. Only issues on statuses the target workflow does
  not list are remapped, and remapping never hands issues off.
- Issue writes keep the status inside the project's workflow. Entering a
  step with a handler through a status change assigns the issue to that
  handler and starts its run with the step's brief as the handoff note;
  an explicit assignee in the same write wins. Creating an issue starts
  at the workflow's starting step.
- Task claims carry the issue's project workflow, and the brief renders
  a Project Workflow section so agents move along this project's
  statuses instead of the built-in ones.
- Statuses a workflow lists cannot be archived.
- Adoption is gated by the project_workflows_v1 flag; existing
  workflows keep working and projects can always switch back to Default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): workflow settings, project picker and workflow-aware boards (MUL-7420)

- Settings → Workflows (behind project_workflows_v1): the Default
  workflow plus named workflows, each shown as its step chain with
  handlers and the projects using it; an editor to pick statuses from the
  library, order them, mark the starting step, and set each step's
  handoff, instructions and next/back statuses.
- Project Workflow property: switching dry-runs first and asks where
  issues on unlisted statuses go; nothing else moves and nothing is
  handed off.
- A project board's columns are its workflow's steps, and each column
  head names who entering it hands the issue to.
- Status pickers offer only the issue's workflow steps and say who each
  hands off to; boards refuse drops onto statuses the dragged card's
  workflow does not list.
- New issues in a workflow project start on its starting step, with a
  hint naming who they will be handed to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): name built-in steps once in the brief; roomier picker hints (MUL-7420)

Built-in statuses have no stored name, so the handoff brief printed
`todo` todo. Print the key alone when the name adds nothing.

The status picker widens for workflow projects and shows the handoff as
an arrow and name, so step names no longer truncate to one letter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): handoff previews and a single timeline entry per handoff (MUL-7420)

- GET /api/issues/{id}/workflow-handoff?status= says what moving an issue
  would do: who it goes to, which runs of its current agent are still
  active, and the brief the handler receives. Backs the confirmation.
- POST /api/issue-workflows/preview-brief renders a step's brief from an
  unsaved workflow, for the editor's preview.
- Mapping plans list the statuses that stay, so the switch dialog can say
  which issues are untouched.
- A handoff is recorded on the status_changed activity (workflow, handler,
  whether a run started, the brief) instead of a separate assignee row.
- The brief now reads like the one shown in the design: the workflow, the
  current step, its instructions, the statuses, and the commands to hand
  off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): rebuild the workflow UI to the reviewed design (MUL-7420)

The first cut used generic controls where the design had purpose-built
ones. This brings every surface up to the mockup:

- Settings → Workflows is one page: the workflows (step chain with
  handler avatars, handoff count, projects using it; Default first) and,
  below, the shared status library with each status's workflow usage.
- The workflow editor is a full page instead of a dialog: draggable step
  cards summarising each handoff, and a panel for the selected step —
  swap its status, pick the handler with a segmented control and a
  searchable target, handoff instructions, next/back statuses, a preview
  of the brief the handler receives, duplicate and delete.
- The project Workflow picker lists each workflow's statuses, handoffs and
  projects, links to settings, and always confirms the switch with what
  moves and what stays.
- Moving an issue into a handoff step (status picker, table, board drop)
  asks first: who it goes from and to, a default-on option to stop the
  previous agent's run, and the brief.
- Status pickers name the workflow and project, show each step's handler
  with an avatar or that the assignee stays, and explain themselves.
- Boards: the handler chip in project column heads, hatched columns a
  dragged card cannot enter with the reason, and a drop hint naming who
  the card will go to.
- The timeline records a handoff under its status change, with the brief
  the run received; the issue sidebar shows the workflow it follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): word a queued previous run as queued in the handoff confirm

The confirm dialog said the previous agent was "still running (for N
minutes)" even when its task had only been queued. Queued runs now read
as queued, and the checkbox cancels them instead of stopping them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): refuse a run's status change once the issue left its step

A run decides where the issue goes next from the step it was queued for.
When someone else moved the issue while the run worked, applying that
decision undid their move: in the live Grok run, a PM parked NW-1 as
blocked and the still-running agent moved it straight back to code
review.

Tasks now record the issue's status when they are queued
(agent_task_queue.workflow_step, written by the task INSERTs; automatic
retries keep their parent's step), and a run's own status changes move
it along. In a workflow project, an agent status change from a run whose
step no longer matches the issue is refused with 409
workflow_step_moved, naming where the issue is now and who moved it, so
the agent stops instead of retrying.

A handoff away from a squad now also stops the runs made on the squad's
behalf, and stopped runs record who stopped them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): keep every status entry point inside the issue's workflow

The issue context menu listed every catalog status and applied handoff
steps without the confirmation the status picker and board show, so a
right-click could pick a status the workflow refuses or start an agent
unasked. It now offers the workflow's steps and opens the handoff
confirmation through a shared modal.

One-click status actions no longer assume built-in statuses a workflow
may not list: "mark done" lands on the workflow's done step (hidden when
it has none), un-marking a duplicate returns to the workflow's start,
and "mark duplicate" is offered only when the workflow lists cancelled.
The batch toolbar offers a single project's workflow statuses when the
selection sits in one project.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): keep issue wakeups consistent with workflow steps

Three places where project workflows and issue wakeups met:

- A wakeup run is queued when it fires, so it took the issue's status at
  that moment as its workflow step. A wakeup an agent registered then
  bypassed the stale-step refusal: parked by a PM, the issue could be
  moved on by the agent's "check CI, then hand to review" wakeup. It now
  keeps the registering run's step; a member's wakeup still starts from
  the issue's status.
- Retries carried handoff_note only for wakeup runs, so a retried
  workflow handoff lost its brief. Rows with a workflow step keep it.
- Switching or editing a workflow remaps statuses in bulk without the
  close rule, so an issue remapped into a done status kept its wakeups
  and pending wakeup runs. The remap now ends them like any status write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): don't also wake the agent a write hands the issue to

A workflow handoff or an assignment already starts the receiving agent's
run. If that agent also subscribed an event wakeup to the issue's status
or assignee, the same write queued a second run for one fact, and
workflows made such writes frequent.

The write now names the agent it hands the issue to in the
transaction-local multica.handed_to_agent (the new agent assignee, a
squad's leader, or a step's handler even when it already owned the
issue), and wakeup capture skips that agent's subscriptions for the
write's issue.* events. Other agents' wakeups, and task, comment and
reaction events, are captured as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): tell agents not to watch workflow issues with wakeups

The handoff brief and the runtime's project workflow section now say
that each step's handler gets a run when the issue enters the step, so
wakeups on a workflow issue are for waiting on time or on something
outside it, not on its status, assignee or runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): refresh project usage after project changes

Co-authored-by: multica-agent <[email protected]>

* feat(workflows): MUL-7420 split columns from per-project handoffs

Workflows now only define a board's columns between a fixed head
(backlog, todo) and tail (blocked, done, cancelled). Handoffs move to
project.workflow_handoffs, so projects can share columns and still hand
off to different people. Default becomes a real, editable row that every
project without a named workflow follows. Adds project handoff, add-column
(project fork or shared) and project brief preview endpoints; same-agent
adjacent columns re-run the agent.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(core): MUL-7420 project handoffs and Default workflow client model

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): MUL-7420 column-header handoffs and project workflow menu

Board column headers set the project's handoff for that column; the
project toolbar gets a workflow menu (full flow, switch, templates) and an
add-column action. Views outside a project show Default's columns, with
other statuses reachable through a status filter. Adds the issue flow card,
create-project workflow choice, a columns-only workflow editor and a
lighter status picker.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): MUL-7420 wrap create-project workflow label in a menu group

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): MUL-7420 keep workflow structure in settings; no squad handoffs

Projects no longer add columns or create workflows: the add-column
endpoint is removed and creating a workflow is admin-only. Handoffs go
to one agent, a member or the project lead; squads route work themselves,
so a handoff to one is rejected, as is the issue-creator handler.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(core): MUL-7420 drop the project add-column client

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): MUL-7420 simpler handoff picker and workflow editor form

The handoff form is one searchable "Hand to" picker (no handoff, the
project lead, an agent or a member); the next status defaults to the next
column and next/back overrides sit under "More options". The workflow
editor gets labelled name and description fields and shows the fixed
statuses as one start and one end row. Templates move from the project
menu to Settings → Workflows → New, and the project board loses its
add-column action.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(server): MUL-7420 keep handoffs on the workflow, make backlog and blocked optional

Handoffs move back onto workflow steps: a workflow is the whole pipeline,
and a project only picks one. The project lead stays a relative handler, so
projects can still share a workflow. Handoff edits are admin-only, through
the workflow write or PUT /api/issue-workflows/{id}/handoffs/{status}; the
brief preview moves to the workflow too. Drops the unreleased
project.workflow_handoffs migration.

Todo, done and cancelled are required; backlog and blocked are optional and
keep their fixed places. Without blocked, the brief points agents at the
step's back status.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Cursor <[email protected]>

* feat(views): MUL-7420 edit handoffs on the workflow, optional backlog and blocked

The workflow editor sets each column's handoff in its side panel, and
Backlog and Blocked switch on and off. The board's column popover and the
flow panel edit the workflow's handoff, say how many projects it reaches,
and are read-only for non-admins. Drops the per-project handoff client
code.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): MUL-7420 keep platform rules on writes that hand off nobody

Migration 579 skips an issue.* event for the agent a write hands the issue
to. Its filter compared agent_id with the transaction's handed_to_agent
setting using IS DISTINCT FROM, which is false when both are NULL. Main's
platform rules (child_done) have no agent_id, so every issue.* event that
handed the issue to nobody was dropped for them: a parent leaving backlog
never woke its agent for sub-issues that closed meanwhile
(TestChildDoneHeldWhileParentInBacklog).

Rules without an agent are never the handoff target, so they always
capture. Extends TestHandoffDoesNotAlsoWakeTheHandler with a platform rule
on a handoff and on a plain status change; the latter fails on the old
filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): MUL-7420 three-way handoff choice; quieter move confirm

The handoff form picks one of three directions, as in the mockup: no
handoff, hand to an agent (starts a run), or hand to a member, project lead
included (assignee only). What the step does, where it goes next and the
back status only reach an agent's brief, so they show and save for agent
handoffs alone.

The board's move confirmation keeps the agent's brief collapsed. Create
project shows the workflow picker even when Default is the only workflow,
with a note on where to make more. The hidden-columns panel says a board's
columns come from its workflow, with a link to manage workflows for admins.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(workflows): confirm handoff on project move, per-project handoff copy, two-pane editor (MUL-7420)

- A single issue moved into a workflow project from issue detail confirms
  the handoff of the column it lands on; the write opts in with
  enter_workflow_step. Batch, API and CLI moves stay administrative.
- The handoff preview takes project_id to preview such a move.
- Saving a shared workflow's handoff from a board can keep the change to
  that project: the server copies the workflow for it and switches the
  project in one transaction (copy_for_project_id).
- The column popover says which workflow it edits and who else uses it.
- The workflow editor is a step list with a detail panel for the
  selected step.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* style: gofmt workflow tests

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): MUL-7420 show next/back always; edit workflow name and description in place

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): MUL-7420 trim copy on the workflows settings page

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix: MUL-7420 keep pre-workflow behavior for workspaces that never adopt workflows

The run step guard now applies only to workflows that hand off, and the
standalone Statuses settings page keeps its original header.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): MUL-7420 use a solid placeholder tone on the workflow header

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(workflows): keep the runtime brief cache-stable; fix project board and batch status gaps (MUL-7420)

- Move the project workflow out of the runtime brief into the per-turn
  prompt so CLAUDE.md stays byte-identical across steps and issues.
- An explicit status filter overrides the project workflow's columns; the
  hidden-columns panel lists statuses outside the workflow.
- Batch status offers only statuses every selected workflow allows; a
  partial batch rolls back, refetches and warns.
- setProjectWorkflow returns void instead of an unparsed body.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Lambda <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-16 16:49:49 +08:00
Frezc 0a979b1f9c fix(search): classify client cancellation separately from server errors (MUL-7989) (#9209)
* fix(search): classify client cancellation separately from server errors

* ci: rerun checks for search cancellation fix
2026-10-13 14:38:36 +08:00
2penheimerandCursor 41c9bbafec fix(projects): preserve omitted fields in concurrent updates (#9111)
Co-authored-by: Cursor <[email protected]>
2026-10-12 12:47:28 +08:00
70ea4c8f0d MUL-7050 perf(search): remove unused exact search counts (#8033)
* perf(search): remove unused exact issue count

Co-authored-by: multica-agent <[email protected]>

* perf(search): remove unused project count

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 16:53:05 +08:00
db86c75e12 MUL-6878: avoid per-row issue status resolution (#7814)
* perf(server): avoid per-row issue status resolution

Co-authored-by: multica-agent <[email protected]>

* fix(server): address issue status query review nits

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 16:00:45 +08:00
5f4ba41d13 MUL-6323 fix(views): stop blaming the daemon when the backend is the stale half (#7126)
* fix(views): stop blaming the daemon when the backend is the stale half (MUL-6323)

A self-hosted user on macOS Desktop could not pick worktree mode, and the
dialog explained why: "the Multica on that machine is v0.4.28; parallel needs
0.4.24 or newer". v0.4.28 was the newest release at the time. There was no
version left to upgrade to (#7113).

Nothing has gated on a version since MUL-5707 moved all three gates onto the
declared local-worktree-v1 capability. The floor survived only as the number
this copy interpolated, so every capability miss — whatever caused it — was
reported as a version shortfall the user had already cleared.

Underneath was a second problem the copy could not express. The capability is
recorded by the SERVER at registration, and servers before v0.4.25 write
runtime metadata with no capabilities key at all. Desktop ships its own
renderer AND its own daemon and updates both together, so the client routinely
runs ahead of a backend the operator upgrades by hand: the daemon advertises
the capability, the old backend drops it, and the UI blames the machine that
was already newest. Upgrading it changes nothing.

So the gate now answers with LocalWorktreeSupport instead of a boolean, and
the discriminator is the PRESENCE of the capabilities key rather than its
contents — a capability-aware server always writes it, null when the daemon
sent no header, so a row without it can only have come from an older server.
The two negatives get their own copy pointing at the half that can actually be
fixed, and no message quotes a daemon version any more; the server's own 422
was rewritten the same way in #6904.

Also records capabilities on runtime-profile registration failure rows. Those
are written after the successful runtimes of the same request, so until the
first heartbeat they are the newest row for the daemon — which is the row
every worktree gate reads. A failed custom profile briefly made a capable
daemon look incapable, and would now have made a current backend look stale.

Removes localWorktreeSupported and MIN_LOCAL_WORKTREE_CLI_VERSION from the
frontend: the first had no callers left, the second only fed the copy this
change deletes.

Co-authored-by: multica-agent <[email protected]>

* fix(views): ask the live backend whether it records capabilities (MUL-6323)

Review follow-up. The first pass read a missing `capabilities` key as "this
backend predates the handshake", but the key's absence only proves the server
was old WHEN THE ROW WAS WRITTEN. Upgrading the backend does not rewrite
runtime metadata and heartbeats touch only last_seen_at, so an upgraded
deployment would keep being told it was too old — and upgrading it again would
change nothing. The daemon does not re-register on a timer, so that state can
sit there indefinitely.

The question is now asked of the server actually in front of us, via
/api/config's server_version, and the stale-row case gets its own answer: both
halves are fine, the machine just has to register again. An ABSENT version
reads as capability-aware, which is the load-bearing default — /api/config
omits the field on the managed cloud by design and leaves it empty for
unstamped dev builds, while the field itself shipped in v0.4.2, 23 patches
before the handshake. So every self-hosted release that could genuinely be
blind still names itself, and cloud users are never told to upgrade a backend
they do not run.

Replaces the cross-side version-floor test rather than deleting the contract.
It pinned MIN_LOCAL_WORKTREE_CLI_VERSION against a frontend twin that this
branch removed, and the number it guarded is one the UI no longer shows. What
the two sides must still agree on is the capability token: the daemon
advertises that exact string, the server stores it, the UI looks for it, and a
typo on either side disables worktree mode for everyone with no error anywhere.

Also corrects a comment that claimed no version numbers appear in this copy —
the backend floor does, deliberately, because it is what an operator upgrades
past.

Co-authored-by: multica-agent <[email protected]>

* fix(views): stop assuming an unnamed backend records capabilities (MUL-6323)

Review follow-up. Reading an absent server_version as "capability-aware" was
right for two of its three sources and wrong for the third: a self-hosted
server older than v0.4.2 — where the field itself shipped — reports no version
AND predates the handshake, so it records no capabilities either. Such a
deployment got told to restart Multica on the machine, which re-registered
against the same blind server and wrote the same key-less row. A loop whose
only real exit, upgrading the backend, the copy never mentioned.

So awareness is three-valued now, and an unnamed server is resolved from
evidence rather than assumption: if ANY runtime row in the workspace carries a
capabilities key, some capability-aware server wrote it and servers move
forward, so the deployment is aware whoever the row belongs to. That settles
the managed-cloud shape — no version reported, several machines, one of them
registered before the handshake — without a version to read. The converse is
never inferred; every row missing the key is equally what an old self-hosted
server and a one-machine workspace look like.

When nothing identifies the backend the UI says so and names both remedies,
still failing closed. Guessing wrong in either direction sends someone to
upgrade a backend they do not run, or to restart a machine that was never the
problem.

Also rewrites the re-register copy, which explained itself in the vocabulary of
the handshake rather than the user's.

Co-authored-by: multica-agent <[email protected]>

* refactor(views): let the server answer whether a machine can run worktree (MUL-6323)

The client-side gate kept growing because it was trying to predict an answer
the server already gives. Each round added a state: capability present or
absent, then the backend's own version to interpret the absence, then evidence
from other rows to interpret an unnamed backend. Three layers of inference over
data the backend wrote, to avoid asking the backend.

So it asks. The UI no longer blocks worktree mode on capability at all — only
on `not_git`, which is local, certain and already checked by the picker. The
mode stays selectable, the save is validated server-side, and the rejection
lands in the dialog's existing errorMessage slot with the wording #6904 wrote
for it. That deletes the awareness tri-state, the workspace-wide evidence scan,
the server-version plumbing, three UI reasons and four copy strings in four
languages.

This also fixes the reported case outright rather than explaining it: a
self-hosted backend older than the handshake runs the version gate it shipped
with, which accepts a v0.4.28 daemon, so the user simply gets worktree mode
instead of an instruction to upgrade something.

The capability signal survives for one job — deciding whether to PRESELECT
parallel mode — where being wrong costs a radio button rather than a blocked
user. A machine that has not advertised is not preselected into a mode whose
rejection would fail the whole project creation, but it stays selectable,
because an old row is not proof the machine cannot.

For any of that to be true the bundled create path had to actually ask: POST
/api/projects attaches resources in one transaction and never ran the worktree
gate that POST/PUT /resources have always run, so it would happily store a mode
the machine cannot run and leave the claim gate to cancel the task later. It
runs the same gate now, before the transaction opens.

Co-authored-by: multica-agent <[email protected]>

* fix: refuse worktree mode until the server says it will honour it (MUL-6323)

Review caught a safety regression in the previous commit, and it is the worse
half of this issue rather than a variant of it.

Deferring to the server assumed the server would object. Releases before
v0.4.25 do not: `validateLocalDirectoryRef` unmarshals the ref into a struct
with no ExecutionMode field and re-marshals it, so `execution_mode: "worktree"`
is silently DROPPED and answered 201. The daemon then reads a ref with no mode,
treats that as in_place by design, and edits the working copy the user asked it
to stay out of. No save gate, no claim gate, no error — on the exact
new-Desktop-old-backend pairing this issue is about. My earlier claim that such
a server would run its own version gate was wrong: worktree shipped in v0.4.25,
so v0.4.24 has no concept of the mode at all.

The client cannot tell that success from a real one, so it asks first.
/api/config now carries local_worktree_supported, a property of the build: if
this code is running, the save gate is running with it. Absent — which is what
every too-old server sends — blocks the mode with copy naming the server, and
the schema parses the missing key to false rather than undefined so a consumer
cannot forget to coerce it.

That keeps the simplification: one declared boolean from the live server, no
inference over rows or version strings, and per-machine capability still left
to the server's save-time gate.

Also clears review nits: a comment still naming the removed localWorktreeSupport
and stray blank runs in two test files.

Co-authored-by: multica-agent <[email protected]>

* fix(views): rename a local directory without resending its ref (MUL-6323)

Review follow-up, and the same failure through a door the last commit left
open. The client now refuses worktree mode when the server cannot promise to
honour it — but renaming an EXISTING worktree resource sent the whole
resource_ref back, capability check or not.

The server replaces the ref with whatever it can parse. On a backend rolled
back below v0.4.25 — documented as supported while the runtimes stay current —
that means execution_mode is dropped and answered 200, and the next task edits
the working copy the resource asked to isolate. The user changed a display
name; nothing told them isolation went with it.

So rename stops sending the ref at all. The update endpoint has always treated
an omitted resource_ref as "keep the stored one", including in v0.4.24, so the
mode survives on every server version and no capability check is needed for a
rename. It also closes the general shape of this: any unrelated edit that
resends a ref gets it re-normalised by whatever the server understands today.

The label therefore moves to the top-level column, which is what the API
already offered for exactly this, and display now reads that first and the
ref's copy second — so rows created before this keep the name they were given
until someone renames them.

Also corrects wording review flagged as misleading: the config flag is absent
on every server that predates the SIGNAL, not only on those that predate the
fix. Releases between the two do gate the save but cannot say so, and are
treated as unable — the client cannot tell them apart, and only one of the two
guesses is safe.

Co-authored-by: multica-agent <[email protected]>

* fix(server): converge the two homes of a local directory's display name (MUL-6323)

Review follow-up. Renaming stopped resending the ref so old servers cannot
strip execution_mode — but that left the name with two writable homes: the
top-level label column (what newer clients write) and the copy inside
resource_ref (what desktop <= v0.4.28 both reads first and writes renames
into). One row could show different names on different devices, permanently,
and clearing a name resurrected the ref copy.

The server is the only writer that sees both client generations, so it now
converges the copies on every local_directory write:

- a label-only rename patches the ref's copy too — as a raw-map edit around
  the other keys, so execution_mode and fields written by a NEWER server
  survive byte-for-byte (re-marshaling through this binary's struct is the
  exact silent-drop this PR exists to close);
- a resent ref whose embedded label CHANGED is the legacy rename shape, and
  the column follows it; an unchanged embedded label (the mode dialog spreads
  the stored ref back) never overwrites a column rename — the write heals the
  row toward the column instead;
- clearing removes both copies, while a column that was merely never set
  keeps its hands off the ref: for rows created by older clients the ref
  copy IS the name.

The display-order helper moves to its own pure module shared by the resources
section and the chat hint (review nit), with the read-order matrix pinned in a
node-environment test beside it.

Co-authored-by: multica-agent <[email protected]>

* fix(server): tell a rename apart from a ref that went stale (MUL-6323)

Review follow-up. Convergence read "the ref arrived with a different label" as
"an old client renamed this", but that is also what a stale snapshot looks
like. The execution-mode dialog captures the whole ref when it OPENS and sends
it back on save, so: device A opens the dialog, device B renames the folder,
device A saves a mode — and the server writes B's rename back to the old name,
from a dialog that has nothing to do with naming.

Only a ref whose SOLE difference is its label counts as a rename now. Anything
else is an edit carrying a name it happens to have been holding, so the row
keeps whatever it is called today — in both homes, which also heals the stale
copy the request brought. Compared as decoded values, and unknown keys count:
a difference this binary cannot interpret is still a difference, and reading
such a request as a pure rename would be a guess.

The same classification fixes a second complaint from the other direction. An
old client's rename resends the ref, so the worktree capability gate refused it
whenever the machine's runtime registration had drifted — failing an edit that
changes nothing about what runs, which is exactly what that gate's comment
already promised label-only updates would be spared. A rename skips it; the row
already says worktree, and the claim gate is what keeps it from running
somewhere that cannot.

TestProjectResourceUpdateLifecycle pinned the old reading for a request that
changes path, daemon and label at once. No client sends that shape — renames
change only the label, the mode dialog changes only the mode — and honouring an
embedded label there is indistinguishable from honouring a stale one, so it now
pins the conservative answer. Callers that mean to rename have the top-level
label field.

Also softens a claim review flagged as overstated: the label patch preserves
every unknown key and value, not the raw bytes, which JSONB does not promise
either.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:49:01 +08:00
Naiyuan QingandClaude Fable 5 984a2c2bff MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* feat(issues): server-backed saved issue views API (MUL-4796)

issue_view table (migrations 262-264: table + two CONCURRENTLY partial
indexes) stores named filter definitions: query jsonb is the shared
identity, display jsonb only seeds a user's first open. Scope model is
workspace / my (with scope_variant, forced private) / project (validated
scope_id, deleted with the project in the same app transaction).

issue_view_preference (migration 265) holds each user's view-bar layout
(hidden + order doc) per surface container, last-write-wins.

CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1
feature flag (404 while off, key published to the frontend config).
Authorization: private views 404 for everyone but the owner; shared views
editable by owner or workspace admin; updates use expected_revision -> 409.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(core): saved issue views client core (MUL-4796)

- zod schemas + parseWithFallback for views and preferences (definition
  blobs stay loose records interpreted per definition_version), with
  malformed-response tests per the API-compat rules
- issue-views module: list/create/update/delete + preference queries with
  wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback)
- active-view store (zustand client state, URL is the durable carrier) and
  useActiveIssueView with missing-view detection
- baseline.ts: enum-sanitized FilterSnapshot of a view's query for
  value-level locking, delta chips, and baseline-aware resets
- view-store: clearFilterDimension + resetFiltersTo actions; surface store
  seeding routes server blobs through mergeViewStatePersisted

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): three-bar priority icons with a distinct urgent badge

low/medium/high share one three-bar frame (fill level = severity, ghost
bars at 35%); urgent breaks out of the scale as a filled badge with an
exclamation cutout — it is an interrupt, not a fourth bar. All glyphs
share the same 2-14 optical frame.

Board card: PickerWrapper's bare block div gave its inline-flex trigger a
line box (line-height 24px > 20px button), floating icons above the text
midline — the wrappers are flex containers now, and the priority trigger
gets a constant size-5 box so the row's rhythm doesn't depend on the glyph.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796)

View bar replaces the built-in tab row on Issues and My Issues: built-in
tabs and saved views render as one flat, per-user ordered row that wraps
instead of overflowing, with a menu for new-view / manage. The manage
dialog does drag reorder (translate-only transform, vertical axis lock),
show/hide (anchor built-in unhidable), and edit/delete for owned views.

Open-view semantics: the view's query is the baseline — its values render
checked-and-disabled in the filter menu, never as chips; chips show only
the user's additions and every reset path (chip x, clear, menu reset,
filtered-empty CTA, board/swimlane hide-column) returns to the baseline,
not to empty. Display seeds once per user (view:<id> surface key) and is
free afterwards. Built-in tab click exits; deleted/revoked views fall back
with a toast; ?view= deep links sync on web via a platform hook.

Save dialog doubles as the edit dialog (PATCH with expected_revision ->
409 toast); manager edits seed from the view definition, in-view edits
from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual
frozen anchor), and the reworked display panel round out the header.

Also: filter menu consistency fixes (closeOnClick, calendar apply,
TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor
passthrough, four-locale i18n.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(views): neutralize workspace tab filter while a saved view is open

The Members/Agents tabs are a coarse assignee-type filter carried in the
surface scope, outside the filter-menu dimensions a view can express. It
kept applying underneath an open view, so the same shared view returned
different rows depending on which tab the user stood on when opening it —
with nothing on screen to explain the difference (tabs dimmed, no chip).

While a view is active the workspace scope now resolves to actorKind
"all": a view's results are defined by the view alone, matching what the
dimmed tabs already imply. Deliberately NOT captured into saved queries —
a view condition must be visible and lockable in the filter menu, and
assignee-type has no menu representation (add the menu dimension first if
that need ever materializes; the loose schema takes the field additively).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): workspace scope_variant captures and applies the assignee-type tab

A workspace view now saves which built-in tab it was created from
(members/agents, NULL = all) as scope_variant, alongside the existing
my-scope variants. The save dialog shows a scope selector so the
variant can be switched later; scope_type stays immutable. While a
view is open, its own variant drives the workspace scope's
assignee-type axis instead of whichever tab the user stood on.

- migration 266 widens the scope_variant CHECKs (members/agents on
  workspace, NULL = all; my keeps its four variants; project stays NULL)
- UpdateIssueView persists scope_variant; handler validates the variant
  against the view's scope_type on create and update
- issue-surface applies activeView.scope_variant to effectiveScope,
  replacing the blanket tab neutralization
- issues-header seeds the dialog scope from the live tab

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): project pages get a real assignee-type axis and clearer variant copy

The Members/Agents tabs on project pages were decorative: they wrote the
global tab store but the project query never read it. The project scope
now carries actorKind like the workspace scope (assignee_types composes
with project_id server-side), so the tabs filter for real, and project
views join the same optional scope_variant vocabulary — saved from the
tab, switchable in the dialog, applied while the view is open.

- migration 266 (unmerged) widened in place: project allows
  members/agents, NULL = all
- handler treats workspace and project variants identically; new
  TestProjectIssueViewVariant covers persist / normalize / reject
- project scope key gains a per-tab segment (members/agents) so each tab
  keeps its own display state; the unrestricted tab keeps the old key
- save dialog shows the variant selector for project views; the surface
  now provides the dialog's default variant directly (header-side
  injection removed)
- variant labels spell out the meaning (Assigned to members/agents, Any
  assignee, Any relation) and project hints are variant-aware, x4 locales

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-table): project table scope honors assignee_types

The table/rows channel dropped the Members/Agents narrowing on project
pages: the client's project scope never sent assignee_types and the
server's project branch never compiled it, so switching tabs only
changed the highlight while table rows stayed put (the board/list
channel already filtered). Both sides now share the workspace scope's
optional assignee-type narrowing, with a compile test covering the
predicate and the invalid-type rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): single assignee-type mapping + per-page tab state

One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant)
replaces the four inlined tab->assignee_types literal mappings across
query-plan, the table spec compiler, and the view-variant application.

The assignee-type tab store becomes page-keyed (issues / project:<id>,
persisted with a v0 migration carrying the old global tab to the Issues
page): switching tabs inside a project no longer drags the Issues page or
other projects along. The save-view dialog's default variant now comes
from the header's own page tab, restoring the /issues default that the
surface-side injection had dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): converge all issue surfaces onto the table query channel

The legacy list/grouped query layer duplicated the table channel's scope
semantics behind enable-conditions that were provably always false: list
and status-board ride server status branches, every non-status grouping
and swimlane ride server group branches, and the assignee-board /
per-status load-more / flat-list / flat-export / client-side my:any
3-request merge fallbacks could never execute. Delete the whole layer:

- queries: myIssueListOptions, myIssueAssigneeGroupsOptions,
  issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions,
  fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages /
  fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering)
- mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup
- board/swimlane: the never-taken client-paginated columns, property
  pool loader, hidden-column client counts, legacy footer rows
- surface data hook / controller: dead queries and passthrough fields
- gantt keeps its scheduled-only endpoint and now compiles the
  assignee-type tab into it, restoring the change alongside the cleanup

query-plan shrinks to the scope's non-Table residue: scopeKey, the
gantt filter, and create defaults. issueKeys and cache invalidation
prefixes are untouched; mobile's own data layer is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks

Post-review pass on the convergence refactor:

- The save dialog's default variant now prefers the OPEN view's
  scope_variant over the page tab: saving a copy while an agents-scoped
  view is open must describe the rows on screen, not the tab underneath.
- The dialog scope prop is memoized on primitive projections — a fresh
  object per header render re-armed the dialog's draft-reset effect, so
  any background refetch wiped a half-typed name.
- Tests: persist migration coverage for the page-keyed tab store; gantt
  assignee_types reaches the request and forks the cache key.
- Dead legacy load-more mocks removed from three view test files; stale
  hidden-columns doc comment; unknown hidden-column totals render no
  count instead of a blank; my-relation plan branch is return-exhaustive;
  leftover blank lines.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): keep the grabbing cursor for the whole row drag in manage views

The handle's cursor classes only apply while the pointer hovers the
handle — the vertical-locked drag loses it immediately, and buttons the
pointer crosses flip it to pointer. Mirror the sidebar-resize cursor
contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and
no text selection) document-wide for the drag's duration, toggled by the
DndContext lifecycle with an unmount safety net.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): my views apply their relation at the query layer, not by switching the tab

Opening a my-view used to overwrite the user's relation tab with the
view's variant (VARIANT_TO_TAB), so when an open view vanished
(deleted / access revoked) the fallback landed on the view's tab
instead of where the user actually was. Views are now applied the same
way on all three surfaces: while a view is open, effectiveScope
substitutes the view's variant — assignee-type for workspace/project,
relation for my — and the user's own tab state is never touched.
myRelationForViewVariant joins its sibling mapping in scope.ts with
unit coverage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): ease the save-view dialog's expand and validation transitions

The display-defaults section snapped open/closed and the name-required
hint popped in. The panel now runs the Base UI height transition
(--collapsible-panel-height + data-starting/ending-style — the
tw-animate collapsible keyframes only know Radix's variable, so the
accordion's animate-* classes cannot be reused here), and the hint eases
in with the house animate-in vocabulary. Scoped to this dialog: the
shared Collapsible primitive stays untouched because chat/transcript
panels grow while open and a pinned panel height would clip them.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): filter chips ease in

Chips popped into the bar and the save-view dialog's embedded list with
no transition. Each chip now enters with the house fade+zoom vocabulary,
keyed per dimension so value edits inside an existing chip don't
re-animate. No exit animation by design.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar

The bar's tabs (built-ins and saved views alike) now reorder by drag,
writing the same per-user preference document the manage dialog edits —
one list, two editors. Saved-view tabs gain a context menu:

- Edit: visible to everyone, disabled without manage permission (the
  greyed row is the signal); Delete: rendered only for the owner or a
  workspace admin on shared views — mirroring the server's
  canManageIssueView — and routed through the shared confirm dialog now
  extracted from the manage dialog. The manage dialog's own affordances
  follow the same widened rule (admins included).
- Pin to sidebar: pinned_item grows a 'view' type (migration 267; create
  validates read access so foreign private views 404). Sidebar pin rows
  resolve the view's name via the new GET-by-id options, render the
  view-bar icon, activate the view in the store and navigate to its
  owning surface on click. Flag-off clients keep view pins dormant
  instead of auto-unpinning; deleted views follow the existing
  404-auto-unpin pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): review fixes — guard preference wipes, never auto-unpin views

Two data-loss paths from review, plus hardening:

- A drag landing before the views list loaded pruned the preference
  document against an empty list, silently erasing every saved-view
  entry from order and hidden. savePrefs now refuses to write until the
  list has actually loaded (viewsReady threads down from
  useActiveIssueView).
- View pins are exempt from 404-auto-unpin: the detail endpoint also
  404s when the feature flag is switched off server-side, and a client
  that booted with the flag on cannot tell the difference — a flag flip
  would have deleted every view pin permanently. Deleted views' rows
  hide instead.
- POST /api/pins with item_type=view now sits behind the same feature
  gate as every other view endpoint.
- SortableBarTab: drop dnd-kit's aria attributes (they nested a second
  focusable role=button around the tab), move the wasDragged ref into an
  effect, and clear it after a cancelled drag so the next real click
  isn't swallowed.
- Sidebar view pins carry ?view= in their href so a web reload stays on
  the view; scope resolution collapsed to one branch driving both the
  path and the container key.
- Malformed GET /api/issue-views/{id} responses pinned by test as null
  (hide the row), never an error (which alone may unpin).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): single-row view bar with overflow popover

The bar no longer wraps: tabs that don't fit collapse into a trailing
popover, Linear-style, with two differences — built-ins are first-class
orderable entries, and the whole list (hidden rows dimmed) lives in the
pulled-down panel with drag reorder, per-row actions (edit/delete under
the same permission rule, pin, hide/show) and a new-view entry.

- useSingleRowFit (views/common): hidden mirror row measures natural tab
  widths; ResizeObserver + change-guarded per-commit remeasure computes
  the fitting prefix with trigger reservation. First reusable
  collapse-into-menu primitive in the repo.
- The open view is always visible: when it overflows it takes the
  trigger slot, showing its name + chevron (wider reservation; the
  two-pass promotion settle is monotonic, no oscillation).
- Every tab now shares one max width (max-w-40) and truncates with the
  full name in tooltips/rows.
- ManageViewsDialog retired: the popover carries all of its duties
  (reorder, hide/show incl. built-ins, edit/delete); hiding stays
  impossible for the anchor built-in and still exits a hidden open view.
  DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx;
  orphaned i18n keys dropped, row action keys added x4 locales.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar

The single-row rework overreached: it replaced the existing new/manage
entries and dialog with an all-in-one popover, and the bar's container
collapsed to zero width (content-sized parent + measurement feedback),
rendering an empty header. Back to the intended shape:

- The [layers] menu (new view / manage views) and the manage dialog are
  restored exactly as before; DeleteViewConfirm and ViewBarItem stay
  shared from view-bar-popover.
- The 'more' trigger exists ONLY while tabs overflow, showing just the
  overflowed entries (click to open; per-row edit/delete/pin/hide with
  the same permission rules — those tabs have no rendered surface to
  right-click). The open view still takes the trigger slot when it
  overflows.
- Header view-bar slots gain flex-1 so the fit measures a real width;
  reserve tiers (menu / +more / +promoted) settle monotonically.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible

The pulled-down panel now lists every bar entry (built-ins included) in
bar order with vertical drag reorder — since the bar renders the longest
fitting prefix, reordering in the panel directly decides what sits on
the bar versus tucks away, with a separator marking the current fold.
The trigger no longer appears/disappears with overflow: it is a fixed
part of the bar's right side (still replaced by the open view's own tab
when that view overflows). Row actions (edit/delete/pin/hide) and the
[layers] new/manage entries are unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): kill the post-drop bounce with a synchronous order mirror

The preference mutation is optimistic but its onMutate is async: on drop,
dnd-kit clears the drag transform in the same frame while the reordered
list lands a beat later, so the dropped tab flashed back to its old slot
and then jumped — the manage dialog never bounced because it mirrors the
new order synchronously in the drag handler. Both bar drag and panel
drag now route through applyMove: set a local order mirror in the same
tick, persist, and clear the mirror once the preference data catches up.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): panel row menu is always visible, not hover-revealed

Hover-reveal made the three-dot row menu unreachable on touch devices;
it is a primary affordance in the list panel, so it renders always.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): long view names truncate/wrap everywhere

The manage dialog's row strip is a grid item — min-width:auto let an
80-char name blow past the dialog frame, carrying the row controls with
it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0)
so labels truncate as designed. The delete confirmation and the save
dialog's scope hint interpolate names/titles into prose — break-words
so an unbroken name wraps instead of overflowing. Audited every other
name surface (bar tabs, promoted trigger, panel rows, sidebar pins,
context menus): all already bounded.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issue-views): remove the saved_issue_views_v1 feature flag

The feature ships unconditionally. The flag never reached any released
client, so — unlike the retired flags that live on as always-on compat
keys — the key is removed outright on both sides:

- server: the issueViewsEnabled 404 gate drops from every view endpoint,
  the preference endpoints and the view pin type; the key leaves the
  registry and the frontend-public list; gate tests and the per-test
  enable helper go with it
- frontend: the === true checks, the pre-view-bar fallback tab rows in
  both headers, and useActiveIssueView's enabled parameter are deleted;
  the view bar is now the only rendering
- compat holds by construction: a NEW client against an OLD backend gets
  404s on the list/preference queries → empty views, viewsReady stays
  false (no preference writes), sidebar view pins hide without
  auto-unpinning (comment updated to name this as the reason); an OLD
  client against the NEW backend reads an absent flag as false and keeps
  the feature hidden

Full-suite run also surfaced that the workspace deletion manifest never
classified issue_view / issue_view_preference: both now fall with the
other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit
teardown), and the manifest records the decision.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): fit reserve applies even when every tab fits

The all-fit fast path skipped the reserve — a leftover from when the
overflow trigger only rendered on overflow. With the list trigger and
the [layers] menu now permanent, a row whose tabs exactly filled the
container declared everything fitting and overflow-hidden clipped the
trailing chrome. The reserve is now subtracted unconditionally.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): a freshly created view opens itself

Creating a view now activates it on its surface immediately — the view
you just saved is the one you meant to be looking at. Skipped only when
the create response fails to parse (the list refetch still shows it).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): creating a view no longer bounces the surface to the default tab

Activation raced the list invalidation: the fresh view's id was set
active before the refetched list contained it, the stale list read as
'view deleted', and the missing-view fallback kicked the surface back to
the anchor tab (with an 'unavailable' toast). Two-sided fix: the create
mutation seeds the created view into its scope's list cache before
invalidating, and the missing verdict now requires the list to not be
mid-refetch.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep

Addresses the pre-ship review blockers:

- CI styles: text-muted-foreground/70 -> text-faint-foreground (solid
  tone rule) in the header hint and the dialog my-note; the chip emoji
  stack's text-[9px] -> text-micro (type-scale rule). apps/web
  text-contrast + type-scale suites pass.
- migration 269 down normalizes workspace/project members/agents
  variants to NULL before restoring the old constraints — verified on a
  scratch DB with real variant data (up -> write -> down).
- view pins are withheld from the legacy /api/pins contract unless the
  client opts in (?include=view): old Desktop builds classified any
  non-issue pin as a project pin and permanently auto-unpinned it on
  404. New clients opt in; regression test pins one and asserts the
  legacy list never leaks it.
- write hardening: MaxBytesReader (128KB) on view create/update and
  preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500);
  per-owner view quota (100/workspace); list query LIMIT 200. Boundary
  tests for null blob, oversized body, and quota.
- member revoke now sweeps the departed member's PRIVATE views and their
  view-bar preferences in the same transaction (shared views stay, same
  rule as private quick actions) — the migration comments promised this;
  test covers private-gone/shared-stays/prefs-gone.
- sidebar comment no longer claims project ?view= reload support.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): deleting a view sweeps its sidebar pins on every path

View pins never auto-unpin client-side (old-backend safety), so a pin
whose view is gone was invisible in the UI and unremovable forever —
and could leave an empty-looking Pinned group. All three deletion paths
now sweep matching pinned_item rows atomically in the same statement
via CTEs: direct view delete, project deletion (its scoped views), and
member revoke (their private views). Regression tests cover each path.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-10 11:55:05 +08:00
45027c1db6 MUL-5824 fix(search): demote cancelled issues and projects below live work (#6515)
* MUL-5824 fix(search): demote cancelled issues and projects below live work

Cancelled work was outranking live work in both search and the @mention
picker. Three separate causes:

- Issue search ordered by relevance first and status second, so statusRank
  was inert across tiers: a cancelled issue matching a title exactly (tier 1)
  beat an in_progress issue that merely contained the phrase (tier 3). With
  LIMIT 20 applied after ordering, a workspace with many cancelled issues
  could push live work off the page entirely.
- Project search had no status ranking at all, and the command palette
  renders projects above issues — so a cancelled project could be the first
  row of the whole result list.
- The mention picker merged its local issue cache ahead of the server
  results and only then truncated to MAX_ITEMS, so a cached cancelled row
  both outranked and crowded out server-ranked live matches.

Demote cancelled ahead of the relevance tiers in both query builders, and
apply the same partition in the picker before its truncation. 'done' is
untouched: finished work is still worth referencing, only cancelled work was
thrown away.

Exempt direct hits — an exact identifier or exact title means the searcher is
targeting that one record, so it stays on top. Curated mention groups
(current / recent / search) are exempt too: "Current" has to survive the
truncation even when the issue being viewed is itself cancelled.

Co-authored-by: multica-agent <[email protected]>

* MUL-5824 fix(search): demote cancelled across issue+project result lists

Follow-up to review on #6515. The per-type ranking landed server-side, but
every client that shows issues and projects together aggregates two
independently ranked responses — so a cancelled row from one still outranked a
live row from the other:

- The command palette and the mobile search screen render the whole Projects
  list before the whole Issues list, so one cancelled project was the first row
  of the result list next to a live issue.
- The context @mention picker concatenates issues then projects and tags both
  'search', while the client-side demotion only touched *ungrouped* cancelled
  issues. Cancelled search results were exempt and cancelled projects never
  participated at all.

Move the partition to the aggregation point and make it cross-type. New shared
helper @multica/core/search/cancelled-rank does one stable partition over both
types, preserving the server's relative order on each side and mirroring the
server's direct-hit exemption (exact identifier, bare number, exact title).

All three consumers now render live projects, then live issues, then one
trailing Cancelled section. A single trailing section is the only arrangement in
which no cancelled row of either type can precede a live row of the other, and
because it runs before truncation the cancelled tail is what gets dropped rather
than a live row. The mention picker keeps its Current / Recent exemptions: those
are explicit context, and "Current" has to survive truncation even when the
issue on screen is itself cancelled.

Mobile's row builder moved to apps/mobile/lib/search-rows.ts so the ordering is
testable without mounting the screen.

Also corrects the include_closed note in search_cancelled_rank_test.go: the
mobile mention picker sends include_closed=false, so it never sees cancelled
issues at all.

Tests: mixed issue/project regressions for the palette (3), the context mention
picker (4), the mobile row builder (6), and the shared helper (9). Each was
confirmed to fail with the partition neutralised.

Co-authored-by: multica-agent <[email protected]>

* MUL-5824 fix(mention): pin cancelled direct hits above the truncation

Second review round on #6515. The mention picker only exempted the curated
Current / Recent groups, so a cancelled issue or project the user had typed out
in full was still demoted — contradicting the direct-hit rule the backend and
the other two search surfaces already share.

The picker now applies that same query-aware rule via isIssueDirectHit /
isProjectDirectHit: exact identifier, bare number, or full title for issues, and
full title for projects. Both the partition and groupItems() take the query so
they cannot disagree about which rows are exempt.

Exemption alone was not enough. slice(0, MAX_ITEMS) runs on the merged list, so a
direct hit behind a full window of cached candidates was still cut — a stale
"row is still listed" claim in the old comment. The partition is now three
tiers, pinned → live → cancelled, where pinned holds curated context plus direct
hits. This mirrors the server, which already ranks direct hits first, and makes
"Current survives the truncation" structural rather than incidental.

isIssueDirectHit now takes all fields optional and derives the number from the
identifier when `number` is absent, which is what lets the picker share the rule:
its rows carry the identifier in `label` and the title in `description`, with no
number field. An explicit `number` still wins if both are present.

Tests: 5 mention regressions (exact identifier, bare number, project full title,
direct hit visible behind 20 live candidates, and a partial match that must still
be demoted) plus 3 helper cases for the identifier-derived number. Verified the
four ordering tests fail with the exemption reverted, and that three of them
still fail with exemption but no pinning — so the pinning tier is load-bearing,
not belt-and-braces.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-08-07 12:33:47 +08:00
a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

Co-authored-by: multica-agent <[email protected]>

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-24 11:30:27 +08:00
bf288349f6 feat(project): add start_date and due_date fields (MUL-4388) (#5313)
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.

- migration 166: two nullable DATE columns on `project` (calendar days, no
  FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
  narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
  update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
  nullable().default(null) so a project from an older backend (frontend
  deploys before backend) parses to null instead of degrading the batch to
  the empty fallback; added a search schema drift test
- projects skill / CLI docs

Part of #5227

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-13 13:22:02 +08:00
359ef61dc3 fix(search): pg_trgm index fallback + statement_timeout guard (MUL-4059) (#4925)
* fix(search): add pg_trgm index fallback + statement_timeout guard (MUL-4059)

Root cause of the "search freezes with no response" symptom reported in
MUL-4059: the search handler runs LOWER(col) LIKE '%pattern%' queries
that expect a pg_bigm GIN index (migrations 032, 033, 036), but every
migration wraps the CREATE EXTENSION + CREATE INDEX in a DO/EXCEPTION
handler that silently skips when pg_bigm is unavailable. The bundled
self-host / dev / CI Postgres image (pgvector/pgvector:pg17) does not
ship pg_bigm, so on every self-hosted deployment the migrations no-op
and no GIN indexes get built. Every /api/issues/search + /api/projects/search
request then falls back to a Seq Scan on `issue` + correlated Seq Scans
on `comment` — verified with EXPLAIN on the local dev DB, which has zero
title/description/comment search indexes before this change.

Two independent guardrails are added, either of which alone would have
prevented the reported hang:

1) Migration 134 installs pg_trgm (ships in all standard Postgres +
   pgvector images) and builds GIN indexes with gin_trgm_ops on
   `LOWER(title)`, `LOWER(COALESCE(description, ''))`, and
   `LOWER(content)`. The expression signatures match the search
   handler's WHERE clauses exactly, so the planner picks the index
   without further changes. The pg_bigm indexes from 036 are left
   intact — deployments on AWS RDS with pg_bigm 1.2 keep the CJK-friendly
   bigram path; deployments without it get the trigram fallback. Verified
   against a local 25k-row fixture: the description LIKE hits
   `Bitmap Index Scan on idx_issue_description_trgm` in 0.5 ms.

2) runSearchQuery wraps both search handlers in a short-lived read-only
   transaction with SET LOCAL statement_timeout = 3 s. In the pathological
   case where indexes are still missing or the query plan is bad, callers
   see a fast 503 with a descriptive error instead of a stalled request.
   Verified against a live Postgres: a deliberate pg_sleep(2) with the
   test override at 200 ms is cut off in 230 ms with SQLSTATE 57014, as
   asserted by TestRunSearchQuery_StatementTimeoutFires.

Non-goals: this change does not remove the pg_bigm code path, does not
change the SQL the handler builds, and does not change the API response
shape. It is the minimum diff to unblock production while preserving
the CJK-search advantage that pg_bigm provides where it is available.

Co-authored-by: multica-agent <[email protected]>

* fix(search): scope comment subqueries to workspace to unblock prd hang (MUL-4059)

Follow-up correction after PRD investigation: pg_bigm IS installed on
prd `multica-prod` and all five bigm indexes exist in the correct
`LOWER(...) gin_bigm_ops` form. The initial "missing index" hypothesis
was wrong; migration 134 (pg_trgm fallback) still helps self-host but
does not touch the production hang path.

Actual prd EXPLAIN (workspace with 60k issues, keyword "search"):

    Index Scan using idx_issue_workspace on issue i
    Rows Removed by Filter: 59123
    SubPlan 2
      Bitmap Heap Scan on comment c
        Rows Removed by Index Recheck: 1928275
        Heap Blocks: exact=48297 lossy=164696
      Bitmap Index Scan on idx_comment_content_bigm
        rows=536761
    Execution Time: 32345.002 ms

Root cause: the correlated `EXISTS` over `comment` gets rewritten by
the planner into a *hashed* subplan. Without a workspace_id filter in
the subquery, that hashed set covers every comment in every workspace
matching the LIKE — 536k rows for "search" — which spills work_mem
into a lossy bitmap and rechecks 1.9M rows.

Two-part fix:

1. Query rewrite. buildSearchQuery now emits
   `c.workspace_id = $wsParam` inside every comment subquery (WHERE
   phrase match, WHERE multi-term match, tier 7 rank, tier 8 rank, and
   the matched_comment_content COALESCE). The same $4 parameter is
   reused so Postgres treats it as a compile-time constant and pushes
   it into the hashed subplan's key, collapsing the set to this
   workspace's comments.

2. Supporting index (migration 135). New
   `idx_comment_workspace ON comment (workspace_id)`. Without it, the
   pushed-down filter still triggers a Seq Scan on `comment` because
   comment has no btree on workspace_id (only the FK constraint and
   composite (issue_id, ...) indexes).

Locally verified against a repro that mirrors prd (5k issues in the
target workspace, 100k comments in a sibling workspace all containing
"search"): the plan drops from 60 ms (hashed global scan, no support
index) to 3 ms (subplan uses idx_comment_workspace). Prd extrapolation
from the same shape: 32.3 s → tens of milliseconds.

Regression test TestBuildSearchQuery_CommentSubqueryWorkspaceScope
asserts every `FROM comment c` in the generated SQL is followed by a
`c.workspace_id = $4` filter, so a future refactor can't silently
regress the plan back to the global-hash pathology.

The statement_timeout guard from the earlier commit in this branch is
kept — it still bounds the worst case if any future query shape
regresses.

Co-authored-by: multica-agent <[email protected]>

* fix(search): address PR review — unwrap 135 + add project trigram indexes (MUL-4059)

Both must-fix items from GPT-Boy's review:

1. Migration 135 unwrapped. The previous version buried
   `CREATE INDEX idx_comment_workspace` inside `DO $$ ... EXCEPTION
   WHEN OTHERS $$` — exactly the anti-pattern that caused MUL-4059 in
   the first place. `idx_comment_workspace` is not a CJK-bonus fallback;
   it is the critical support that makes the query rewrite land on an
   Index Scan instead of a Seq Scan. A silent failure (lock timeout,
   disk full, permission denied, schema drift) MUST abort the migration
   and fail deployment, not slip through as green. The unwrapped
   `CREATE INDEX IF NOT EXISTS` now propagates real errors to the
   migration runner, which aborts and does NOT record the version as
   applied. IF NOT EXISTS keeps idempotency for the operator-precreated
   case (`CREATE INDEX CONCURRENTLY ...` before running migrations on
   large prd tables).

2. Migration 134 now covers project search too. SearchProjects reads
   `LOWER(project.title)` and `LOWER(COALESCE(project.description, ''))`,
   and the pg_bigm equivalents in migration 039 silently no-op on
   pg_bigm-less images just like 032/033/036. Without the trigram
   fallback, project searches on self-host would still Seq Scan and hit
   the 3 s statement_timeout guard as a 503 — technically bounded but
   not actually fixed. Added `idx_project_title_trgm` and
   `idx_project_description_trgm`; the down migration drops them too.

Also: fixed the search.go comment that said callers get a "standard
500" — they get a 503 with SQLSTATE-57014 mapping; the comment now
matches reality.

Verified: build clean, vet clean, existing search / timeout tests
still green. Migration 135 dry-run (dropping the index, re-applying
the unwrapped SQL under `ON_ERROR_STOP=1`) creates the index cleanly;
a deliberate `CREATE INDEX` on a non-existent column now aborts psql
with exit 3, confirming the migration runner would fail loudly on any
real error.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-06 12:34:51 +08:00
31d942d010 MUL-3438: fix(projects): require admin for project deletion (#4327)
* fix(projects): require admin for project deletion

* test(projects): clean up orphaned member rows in delete-permission helper

The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-21 23:54:58 +08:00
7dc05d28bc fix(projects): validate project status/priority — return 400 instead of 500 (#3925) (#3939)
* fix(projects): return 400 (not 500) for invalid project status/priority

CreateProject/UpdateProject passed an unvalidated status/priority straight to
the INSERT, so an unknown value (e.g. --status active) tripped the table's
CHECK constraint and surfaced as a blanket 500 'failed to create project'
with no server-side log to diagnose it (#3925).

Pre-validate both enums against the column CHECK lists and return a 400 with
the allowed values. Back it with isCheckViolation -> 400 for any other
constrained column, and log the underlying error on genuine 500s so transient
DB failures are diagnosable.

MUL-3153

Co-authored-by: multica-agent <[email protected]>

* fix(cli): validate project --status in create/update

project create and project update forwarded --status to the server without
checking it, while project status already validated. Share a single
validateProjectStatus helper across all three so a typo fails fast with the
valid list instead of a server round-trip.

MUL-3153

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-09 13:54:53 +08:00
Chenerandchener fd1cdf1801 fix project progress cache invalidation (#3016)
Co-authored-by: chener <[email protected]>
2026-06-02 15:05:49 +08:00
341ce7bfa5 feat: support local working directory for projects (MUL-2618 v1) (#3283)
* feat(project): add local_directory project_resource type (MUL-2662)

Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.

Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.

Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.

Co-authored-by: multica-agent <[email protected]>

* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)

Addresses the Elon review on PR #3263:

- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
  matching handler, CLI `project resource update`, and a new
  EventProjectResourceUpdated WS event. resource_type stays immutable;
  ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
  embedded label differs — the row-level UNIQUE only matches the full
  ref JSON, so a label typo would otherwise let the same working
  directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
  invalid path) and the label-shadow conflict on both create and
  update; the in-place rename still succeeds because the conflict
  scan ignores the row being edited.

Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.

Co-authored-by: multica-agent <[email protected]>

* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)

Two follow-ups from MUL-2662 review round 2:

- CreateProject inline resources path now dedupes local_directory entries on
  (daemon_id, local_path) before opening the transaction. The DB-level
  UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
  full JSON match, so two rows with the same target but different `label`
  would otherwise slip past. Standalone POST/PUT already cover this via
  findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
  row before applying per-type shortcut flags, so `--default-branch-hint x`
  on its own no longer constructs a payload missing `url` (which the server
  400s on). Local_directory partial edits get the same merge behavior.

Co-authored-by: multica-agent <[email protected]>

* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)

* feat(desktop): local_directory project_resource UI (MUL-2665)

First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.

What's new for the renderer:

- ProjectResourcesSection grows a desktop-only "Add local directory"
  button next to the existing GitHub-repo popover. Clicking it opens
  Electron's native folder picker, validates the path through a new
  IPC pair (existence + r/w), and submits a project_resource of
  resource_type=local_directory with daemon_id pulled live from
  daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
  greys out when ref.daemon_id != this machine's daemon_id (with a
  "only available on the machine that registered this directory"
  tooltip). Delete stays enabled so users can drop stale registrations
  from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
  shows "Agent will work in-place at {label} ({path})" when the issue's
  project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
  that the daemon will publish when it dequeues a task but can't
  acquire the path lock. The render is in place now so the daemon
  sibling subtask can wire the status string without an additional UI
  PR.

Plumbing:

- @multica/core/types gains LocalDirectoryResourceRef +
  UpdateProjectResourceRequest, and the api client gets the matching
  PUT method backed by the server endpoint that landed in
  2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
  in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
    local-directory:pick     -> dialog.showOpenDialog (openDirectory)
    local-directory:validate -> stat + access(R_OK + W_OK)
  exposed through the preload as desktopAPI.pickDirectory /
  validateLocalDirectory. View code talks to them via a thin
  packages/views/platform helper that returns reason=unsupported on
  web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
  running flag from daemonAPI.onStatusChange so the renderer can do the
  cross-device match without coupling to the desktop preload typings.

Tests:

- pickStageKeys gets a unit test covering the new stage and proving
  the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
  no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
  and chat.status_pill.stages.waiting_for_directory_release in both
  locales.

Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
  new state.
- Adding local_directory to the create-project modal's bulk
  attach flow.
- Docs page refresh for project-resources.mdx — left for the
  MUL-2618 umbrella sweep.

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)

Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)

Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:

  - Validates the path (absolute, exists, daemon process can read+write,
    not in the system-root / $HOME blacklist) and fails the task fast on
    any precondition violation, with a user-readable reason.
  - Serialises concurrent tasks on the same on-disk path via a
    daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
    lock is held for the entire task lifetime (claim → context write →
    agent → result report).
  - When the lock is contended, the daemon flips the row to a new
    waiting_local_directory status on the server (carrying a wait_reason
    like "<path> (held by task <short id>)") so the UI can render
    "等待本地目录释放" instead of leaving the row silently in dispatched
    past the sweeper timeout. The status accepts being woken into running
    once the lock is acquired.
  - Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
    still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
    .gc_meta.json — the daemon's logbook for the run.
  - Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
    envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
    gcActionOrphan → gcActionSkip). The user's directory was never under
    envRoot to begin with, so this is defense in depth.
  - Skips execenv.Reuse for local_directory tasks because the prior
    WorkDir is the user's path and reusing it through that code path
    loses the envRoot association the GC loop needs. Prepare is cheap
    here (no clone, no copy), so always running it is fine.

Server-side protocol changes:

  - New CHECK value 'waiting_local_directory' on agent_task_queue.status
    plus a wait_reason TEXT column (migration 109).
  - All cancel / active / counted-as-running / orphan-recovery queries
    expanded to include the new status; FailStaleTasks intentionally
    excludes it (the daemon owns the wait).
  - New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
    StartAgentTask that accepts both dispatched and
    waiting_local_directory as preconditions (and clears wait_reason on
    the way through).
  - New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
    TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
    daemon Client.MarkTaskWaitingLocalDirectory.

Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.

The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.

This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)

Address the 3 must-fix items from Elon's review of PR #3274.

1. Status string unified. The server / daemon publish
   `waiting_local_directory`; align views, locales, and the
   pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
   on a placeholder string). Without this, the daemon's wait state
   never reached the pill once the two siblings merged.

2. validateLocalPath now also runs the blacklist against the
   symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
   redirect handled via `isBlacklistedRealPath` which compares
   canonical forms. Without this, a symlink such as
   `/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
   while every daemon write still landed in the user's home. Tests
   cover symlink-to-home, symlink-to-system-root, and the negative
   case (symlink to a regular subdirectory).

3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
   watcher inside `onWait` (lazy — the fast path stays free) so the
   gap between dispatch and StartTask responds to server-side cancel
   or row deletion. If the watcher fires while the daemon is parked
   on the path mutex, the lock-wait context is cancelled, Acquire
   returns promptly, and the helper exits silently the same way the
   run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
   exercises the path end-to-end with a fake server.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)

- validateLocalPath now always runs isBlacklistedRealPath on the
  symlink-resolved path, not only when it differs from absPath. The old
  guard let users type the canonical form of an OS-symlinked banned root
  (e.g. /private/tmp, /private/etc, /private/var on macOS) straight
  through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
  onto filepath.VolumeName via a new isDriveRoot helper, so removable /
  network drives mounted at G:..Z: and UNC \\server\share roots are also
  blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
  new TestIsDriveRoot covers the Windows generalisation (skipped on
  POSIX runners by runtime guard).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>

* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)

Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.

- Add `waiting_local_directory` to `AgentTask.status` and the typed
  `task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
  the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
  status label, treat parked tasks the same as dispatched for time anchor /
  transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
  dispatched and queued, and surface a "is waiting for the local directory"
  banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
  the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
  workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
  `execution_log.status_waiting_local_directory` (en + zh-Hans).

Co-authored-by: multica-agent <[email protected]>

* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)

The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:

- server: `findLocalDirectoryConflict` rejects any second row sharing a
  daemon_id, regardless of `local_path` or label. Bundled-create surface in
  `CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
  one row pinned to the current daemon (older API client / direct DB
  writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
  daemon owns a row on this project, with a hint and a defensive toast on
  the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
  * daemon: new `two local_directory rows on this daemon fail fast` /
    `local_directory rows on different daemons coexist` cases.
  * handler: rewrite the legacy `LabelShadow` cases as
    `DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
    asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.

Co-authored-by: multica-agent <[email protected]>

* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)

Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.

Co-authored-by: multica-agent <[email protected]>

* feat(create-project): binary source picker — repos OR local directory

Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.

Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
  folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
  hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
  when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
  only emits the resource matching the active sourceMode, so abandoned
  picks never leak into the created project.

Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.

* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica J <[email protected]>
2026-05-27 13:44:31 +08:00
1c91c2a3b2 security(db): scope DELETE/UpdateIssueStatus by workspace_id (defense-in-depth) (#3027)
* fix(security): scope DELETE/UpdateIssueStatus by workspace_id

Add workspace_id to the WHERE clause of DeleteIssue, DeleteComment,
DeleteProject, DeleteSkill, DeleteChatSession, and UpdateIssueStatus
as SQL-layer defense-in-depth.

Handler loaders (loadIssueForUser / loadSkillForUser / etc.) already
enforce workspace membership today, so this is not patching a known
live vuln. But the tenant invariant is currently a handler-layer
guarantee — a future loader bypass or a new caller skipping the
loader would be silently catastrophic. Making workspace_id part of
the SQL identity collapses the trust surface to the schema itself:
forging a sibling-workspace UUID becomes ErrNoRows instead of a
cross-tenant write.

Reference: incident #1661 (util.ParseUUID silent zero UUID returning
204 on a DELETE that matched zero rows) — same class of failure,
prevented at a different layer.

Scope:
- 5 DELETE queries: issue, comment, project, skill, chat_session
- 1 simple UPDATE: UpdateIssueStatus (2 narg, no SET ordering risk)
- All callers updated (handlers, service, runtime sweeper fallback)

Multi-narg UPDATE queries (UpdateIssue, UpdateProject, UpdateSkill,
UpdateComment, UpdateChatSession*) are deferred to a follow-up to
keep this change reviewable: each needs its narg pinning shifted
and per-caller verification.

sqlc was regenerated by hand (no local sqlc toolchain); CI's
backend job is the authoritative compile check.

* test(security): add workspace_scope_guard regression test

Locks in the SQL-layer tenant guard added in this PR. For each of the 6
scoped queries (DeleteIssue, DeleteComment, DeleteProject, DeleteSkill,
DeleteChatSession, UpdateIssueStatus), creates the resource in workspace
A, invokes the query with a foreign workspace UUID, and asserts the row
is untouched (0 rows affected with no error for :exec; pgx.ErrNoRows for
:one). A future refactor that drops the workspace_id arg from any of
these queries will now fail loudly instead of silently regressing.

Includes a sanity sub-test that the in-workspace path still mutates, so
a buggy guard that returns no-op for every call would not pass.

Co-Authored-By: Claude Opus 4 <[email protected]>

---------

Co-authored-by: Tom Qiao <[email protected]>
Co-authored-by: Claude Opus 4 <[email protected]>
2026-05-22 14:39:47 +08:00
Bohan Jiangandmultica-agent a4fac51cf5 fix(projects): add resource_count breadcrumb instead of inlining resources (#2118)
* fix(projects): add resource_count breadcrumb to project responses

Closes #2087

`multica project get` previously returned project metadata with no signal
that resources existed. Agents that fetched a project this way had no way
to discover its attached resources without already knowing about
`/api/projects/{id}/resources` or the on-disk `.multica/project/resources.json`.

Rather than inline the full resource list into the parent payload (which
conflates parent metadata with a child sub-collection and locks the
resource_ref shape into the project endpoint's contract), this adds a
scalar `resource_count` breadcrumb to ProjectResponse. The actual list
stays at the dedicated sub-collection endpoint.

Changes:
- GetProjectResourceCounts :many — new batched sqlc query
- ProjectResponse.ResourceCount populated in GetProject, ListProjects,
  SearchProjects, and the with-resources CreateProject echo
- multica project get prints a stderr hint pointing at
  multica project resource list <id> when count > 0; the JSON on stdout
  stays parseable
- Meta-skill (runtime_config.go) lists multica project get and
  multica project resource list in Available Commands so agents that
  read CLAUDE.md / AGENTS.md know about both paths

Co-authored-by: multica-agent <[email protected]>

* fix(projects): wire ResourceCount through Update + Create event payload

Review feedback on #2118.

- UpdateProject now reloads ResourceCount before responding/publishing.
  Previously a title- or status-only PUT served (and broadcast over WS)
  resource_count: 0 even when resources existed.
- The with-resources CreateProject path sets resp.ResourceCount before
  the project:created publish, so the WS event payload matches the HTTP
  echo. The hand-rolled response map collapses to an embedded
  ProjectResponse + resources array — one source of truth for the
  serialized shape.
- packages/core/types/project.ts: Project gains resource_count: number
  to keep the TS contract aligned with the server response.

Tests:
- TestProjectResourceCountBreadcrumb extends to assert UpdateProject
  preserves the breadcrumb.
- TestCreateProjectWithResourcesEchoesCount asserts the create echo
  carries resource_count matching the attached resources.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-06 14:09:35 +08:00
Bohan Jiangandmultica-agent 44608713bb feat(projects): typed project resources + agent runtime injection (#1926)
* feat(projects): typed project resources + agent runtime injection

Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.

Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks

Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
  via type-dispatched formatter so new resource types just add a case

CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove

Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove

Docs
- New "Project Resources" chapter explaining the abstraction and
  exactly what code to touch when adding a new resource type

Co-authored-by: multica-agent <[email protected]>

* fix(projects): transactional resources[] on create + generic CLI ref + test fix

Addresses review feedback on PR #1926:

1. CI red: TestProjectResourceLifecycle delete step called withURLParam
   twice, which replaced the chi route context and dropped the project id.
   Switched to the existing withURLParams helper from daemon_test.go.

2. POST /api/projects now accepts resources[] and attaches them in the
   same transaction as the project. Invalid refs roll back the whole
   create — no more half-attached projects on failure. Web modal + CLI
   `project create --repo` both use the new bundled payload.

3. CLI `project resource add` now accepts a generic --ref '<json>' flag
   so a new resource_type works without a CLI change. Per-type
   shortcuts (--url for github_repo) remain as a convenience but are no
   longer the only way in. Docs updated to drop the CLI from the
   "files you must touch" list.

Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-04-30 14:00:43 +08:00
Bohan JiangandEve f628e48775 refactor(server): error-returning ParseUUID to prevent silent data loss
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410)

util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID
on invalid input. When this zero UUID reached a write query (DELETE/UPDATE),
the SQL matched zero rows and the handler returned 2xx success — producing
silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the
visible symptom; PR #1680 patched that one site, this commit closes the
class of bug.

Changes:

- util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID
  for trusted round-trips that should panic on invalid input.
- handler/handler.go: parseUUID wrapper now calls MustParseUUID — any
  unguarded user-input string reaching it surfaces as a recovered panic
  (chi middleware.Recoverer → 500) instead of silently corrupting data.
  Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points.
- Convert every Queries.Delete*/Update* call site reachable from raw user
  input (autopilot, comment, project, skill, skill_file, label, pin,
  attachment, feedback, issue assignee, daemon runtime, workspace) to
  validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on
  invalid input. Where a resolved entity.ID is already in scope, write
  queries now use it directly instead of re-parsing the URL string.
- Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs
  gracefully (404/400 instead of panic).
- Update util/middleware/cmd-level callers (subscriber_listeners,
  notification_listeners, activity_listeners, scope_authorizer,
  middleware/workspace) to use the error-returning API.
- Add server/internal/util/pgx_test.go covering valid/invalid input and
  the MustParseUUID panic contract.
- Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID
  regression tests in handler_test.go (the original #1661 bug + the
  invalid-input case).
- Document the handler UUID parsing convention in CLAUDE.md so the rule
  is enforceable in future PR review.

* fix(server): address GPT-Boy review of #1748

P1 fixes from PR #1748 review:

1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so
   malformed input returns 400 instead of panic/500. Was missing on:
   - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/
     SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id /
     parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id
     filters; batch issue_ids and assignee/parent/project fields in
     BatchUpdateIssues (skip on bad input via util.ParseUUID, matching
     the existing per-row continue semantics).
   - project.go: project id + workspace_id in GetProject/UpdateProject/
     DeleteProject; lead_id in CreateProject/UpdateProject;
     workspace_id in ListProjects + SearchProjects.
   - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID /
     X-Task-ID headers; invalid UUID falls back to "member" (matches
     pre-existing semantics) instead of panicking.
   - issue.go: validateAssigneePair returns 400 on invalid workspace_id
     instead of panicking.

2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID)
   instead of the raw URL string. After an identifier-path delete
   ("MUL-7"), the previous payload would have leaked the identifier to
   subscribers, leaving stale entries in frontend caches that key by
   UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues
   (issue.go:1641). The slog "issue deleted" log line also now records
   the resolved UUID so logs match the WS payload.

3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and
   assert issue:deleted.payload.issue_id is the resolved UUID, not
   the identifier.

* fix(server): validate remaining reviewed UUID inputs

* fix(server): validate remaining handler UUID inputs

* fix(server): finish request boundary UUID audit

* fix(server): validate remaining request body UUIDs

* fix(server): validate runtime path UUIDs

* fix(server): validate remaining audit UUID inputs

---------

Co-authored-by: Eve <[email protected]>
2026-04-28 14:50:28 +08:00
Naiyuan QingandClaude Opus 4.6 f0f3cb5c3a fix(server): resolve X-Workspace-Slug in middleware-less handlers (#1165)
Problem
-------
The v2 workspace URL refactor (#1141) switched the frontend from sending
X-Workspace-ID (UUID) to X-Workspace-Slug. The workspace middleware was
updated to accept the slug and translate it via GetWorkspaceBySlug.

But the handler package maintained a PARALLEL resolver
(`resolveWorkspaceID` in handler.go) used by endpoints that sit outside
the workspace middleware — and that resolver was never updated. It only
checked context / ?workspace_id / X-Workspace-ID, never the slug.

/api/upload-file is the one production route that hit the broken path:
it's user-scoped (not behind workspace middleware) because it also
serves avatar uploads (no workspace). Post-refactor requests from the
frontend arrived with only X-Workspace-Slug; the handler resolver
returned "", the code fell into the "no workspace context" branch, and
every file upload since v2 landed in S3 with no corresponding DB
attachment row — files orphaned, invisible to the UI.

Root cause is structural: two resolvers doing the same job, written
independently, diverged silently when one was updated.

Fix
---
Collapse to a single shared helper. middleware.ResolveWorkspaceIDFromRequest
is the new canonical resolver; both the middleware's internal
`resolveWorkspaceUUID` (for middleware gating) and the handler-side
`(h *Handler).resolveWorkspaceID` (promoted from a package function)
now delegate to it. Priority order matches what the middleware has had
since v2: context > X-Workspace-Slug header > ?workspace_slug query >
X-Workspace-ID header > ?workspace_id query.

Impact analysis
---------------
47 call sites of the old `resolveWorkspaceID(r)` are renamed to
`h.resolveWorkspaceID(r)`. 46 of them sit behind workspace middleware,
so they hit the context fast path and see zero behavior change. The
one caller that actually gains capability is UploadFile — which now
correctly recognizes slug requests and creates DB attachment rows.

Tests
-----
- New table-driven unit test for ResolveWorkspaceIDFromRequest covers
  all priority levels and the unknown-slug fallback.
- Regression tests for UploadFile: once with X-Workspace-Slug only
  (the broken path), once with X-Workspace-ID only (legacy CLI/daemon
  compat path). Both assert that a DB attachment row is created.
- Full Go test suite passes; typecheck + pnpm test unaffected.

Plan
----
See docs/plans/2026-04-16-unify-workspace-identity-resolver.md for the
full first-principles writeup.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 18:01:56 +08:00
Jiayuan Zhang 904192b45c fix(web): correct project kanban issue counts (#667) 2026-04-10 21:13:25 +08:00
Jiayuan ZhangandClaude Opus 4.6 f1d81cdfaa feat(search): add project search support to Cmd+K search (#663)
Projects are now searchable alongside issues in the Cmd+K search dialog.
Results are grouped by type (Projects / Issues) with project icon, status,
and description snippet highlighting.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 20:59:32 +08:00
Jiayuan ZhangandClaude Opus 4.6 356ff002dd feat(projects): show completion progress in project list (#651)
* feat(projects): show completion progress (done/total issues) in project list

Add a progress column to the projects list page that displays a mini progress
bar and done/total issue count for each project. Backend batch-fetches issue
stats per project using a single query for efficiency.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(projects): show progress on project overview page

Add a progress bar with done/total (percentage) to the project detail
overview tab, computed from the already-loaded project issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 18:36:49 +08:00
Bohan Jiang 245beed829 feat(projects): add priority attribute to projects (#565)
Add priority field (urgent/high/medium/low/none) to projects, matching
the existing issue priority system. Includes database migration, API
support for create/update/list filtering, and UI for the create dialog,
project list table, and project detail page.
2026-04-09 16:31:05 +08:00
Bohan Jiang 68e2a14ba2 feat(projects): add Project entity with full-stack CRUD support (#552)
Implements the Project concept as a higher-level grouping for issues.
Hierarchy: workspace → project → issue → sub-issue.

Backend:
- Migration 034: project table + issue.project_id FK
- sqlc queries for project CRUD
- Project handler with list/get/create/update/delete
- Issue handler updated to support project_id in create/update
- Routes at /api/projects, WebSocket event constants

Frontend (new monorepo structure):
- @multica/core: Project types, API client methods, queries/mutations,
  status config, realtime sync
- @multica/views: Projects list page, detail page (overview + issues
  tabs), project picker for issue detail panel
- apps/web: Route pages, sidebar navigation entry

All TypeScript type checks and tests pass.
2026-04-09 14:59:16 +08:00