Commit Graph
10 Commits
Author SHA1 Message Date
bb14e8763c feat(issues): add multi_text and multi_url custom property types (MUL-7496) (#8559)
* feat(issues): add multi_text and multi_url custom property types

Workspaces could only hold list-shaped values as preset option lists
(multi_select) or member references (multi_actor). There was no way to
keep a list of free-form strings or links on an issue, so teams with
"related docs / spec links / aliases" needs had to either cram them into
one text field (no structure, nothing clickable) or burn one select
option per link.

This adds two property types:

- multi_text: an ordered list of free-form text entries
- multi_url: an ordered list of http(s) URLs

Each entry is validated exactly like a single text / url value
(length caps, http(s)-only for URLs), duplicates are dropped, insertion
order is preserved, and values are capped at 20 entries to respect the
16KB properties-bag budget. Values ride the existing jsonb bag, so the
jsonb_path_ops GIN index, the @> containment filter, and the client
value schema all keep working unchanged.

Filtering matches any single list element exactly (equality) plus "No
value" — the containment form the filter compiler already emits for
array elements, so no new server predicate is needed. List types
deliberately get no sort order and cannot group, like the other
multi-value types.

On the web, list values render as chips; multi_url chips are clickable
and open in a new tab, and the editor popover offers a per-entry open
button, so several related documents or links stay directly reachable
from the issue. The filter menu reuses the scalar input (equality-only
plus "No value"). CLI: comma-separated --value form for both types,
element-equality --property filtering, and display_values rows.

Server-side allowlist widened by migration 500 (check constraint
re-added NOT VALID then validated, same pattern as migration 341).

* fix(properties): address list property review nits

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

* fix(properties): scope list drafts to the edited issue

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

* test(agent): avoid fork races when writing zeroclaw fixtures

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-07 13:33:02 +08:00
d36ef1d1bd perf(issues): scope working-agent facet tasks by visible agents (MUL-7838) (#9004)
* perf(issues): scope working-agent facet tasks by visible agents

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

* perf(issues): join working facet agent candidates as a relation

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

---------

Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-01 17:47:46 +08:00
4fe6586b32 MUL-6790 feat(issues): support operator matching in scalar property filters (#7765)
* feat(issues): support operator matching in scalar property filters

Closes #7692.

Scalar custom-property filters (text / number / date / url) previously
matched by exact equality only, so a text fragment or a numeric range
returned an empty list. A property filter member is now either a plain
string (exact equality — the unchanged default that keeps every existing
saved view, URL, and API call meaningful) or an operator object:

- contains — case-insensitive substring (text / url)
- gt / gte / lt / lte — numeric comparison (number)
- before / after — lexicographic comparison of the stored
  "YYYY-MM-DD" strings (date)

Backend: parsePropertiesFilterParam accepts {op, value} members and
compiles them to operator patterns; propertiesFilterPredicate renders
ILIKE / numeric / date-string comparisons and the static ListOpenIssues
unroll in issue.sql mirrors them. contains values are LIKE-escaped at
parse time and ops are fully validated before compilation, so the SQL
branches only ever see legal shapes. The table-filters request carries
members as raw JSON so both shapes survive the round-trip, and the
query fingerprint canonicalizes them byte-exactly.

Frontend: the filter menu grows an operator row above the scalar value
input ("is" is the default and still commits a bare string), the client
matcher in issueMatchesPropertyFilters mirrors the server predicates,
and chips summarize operator members with per-locale phrases or math
symbols. Operator members ride in saved-view baselines and chip deltas
through a canonical propertyFilterValueKey.

Verified: full Go suite (race) and the internal/handler property,
facet, and issue-table tests; new unit + DB-backed operator tests on
both tiers; pnpm typecheck and the core/views Vitest suites including
new operator matrix, menu, and saved-view baseline tests.

* test(issues): cover operator members through the table-rows endpoint

The properties filter serves through three paths — the paginated list,
the open_only static unroll, and the table rows/facets compile — but
only the first two had operator-member coverage. The table path has
PR-specific code (raw-JSON members in issueTableFiltersRequest and the
byte-exact sortedUniqueRawJSON fingerprint) that no test exercised.
Drive ListIssueTableRows with operator members ANDed across two
definitions so the decode → fingerprint → parsePropertiesFilterParam
chain is pinned like the other paths.

* fix(issues): canonicalize numeric operator bounds and align empty-needle contains

Adversarial review findings:

- strconv.ParseFloat accepts forms Postgres ::numeric rejects ("0x1p4"
  hex-float, "1_000" underscores on older PG). The dynamic predicate
  binds the parsed float and succeeds while the static open_only unroll
  casts the raw string inside SQL and 500s — same filter, two list
  endpoints, different outcomes. Canonicalize the compiled pattern
  value with strconv.FormatFloat(num, 'f', -1, 64) at validation time;
  the dynamic re-parse and the static cast now see plain decimals.
  Regression tests cover the hex-float bound on both serving paths.
- issueValueMatchesOperator treated an empty contains needle as
  match-all ("".includes("") is true) while the server rejects empty
  operator values — a hand-edited saved-view blob could match every
  issue client-side. The matcher now refuses empty needles, and the
  test asserts it against a SET value (the unset case short-circuits
  before the operator runs and could never catch this).
- Server e2e: pin unset-key exclusion for contains / before / after on
  both serving paths via the seeded empty issue.

* fix(issues): align scalar property operator semantics

* test(issues): cover boolean contains mismatch

* fix(i18n): use exclusive Japanese wording for date before/after filters

The `before` / `after` operators compare strictly (`<` / `>` on both the
server predicate and the client matcher), so a date equal to the bound is
never a match. Japanese 以前 / 以後 mean "on or before" / "on or after" —
they include the reference date, so a user filtering 「2026-03-01 以前」
reasonably expects March 1 in the results and does not get it.

より前 / より後 carry the exclusive meaning the implementation actually
has. English "before"/"after" and Chinese 早于/晚于 are already exclusive
and are left unchanged.

Korean 이전 / 이후 share the same Sino root and are inclusive by
dictionary definition, but they are also the conventional rendering for
before/after filters in Korean software — left as-is pending a native
speaker's call rather than guessed at.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 17:21:11 +08:00
Deoanddeo 46c9ea38a7 feat(issues): extend property filter to text / number / date / url (MUL-6423) (#7240)
* feat(issues): extend property filter to text / number / date / url

Widen the custom-property filter (value + "No value" / __none__) from
select / multi_select / checkbox / actor to the four scalar types.

Backend:
- parsePropertiesFilterParam emits a jsonb number containment form for
  numeric filter values, guarding against NaN / Infinity (not representable
  as JSON). Date / text / url stay on the string form.
- Property table facets gain text / url / date (string) and number
  branches, replacing the 422 default, with the unset "__none__" bucket.

Frontend:
- isFilterablePropertyType (core) now admits the scalar types; the filter
  menu renders a value input plus a mutually-exclusive "No value" option
  for text / number / date / url (PropertyFilterOptions). The scalar value
  and "No value" are both disabled when a saved view locks the dimension.
- Filter chips show the scalar value directly; the client matcher compares
  number values numerically, matching the server's jsonb containment.
- New store action setPropertyFilterValues replaces a property's full
  filter value set.

Tests cover numeric + NaN/Infinity parse, scalar facet buckets, number
client matching (incl. non-canonical forms), isScalarPropertyType, and
the store action.

Note: a text property value literally stored as "__none__" is reserved —
the sentinel means "no value", and the filter input rejects it.

* fix(issues): address review on scalar property filter (PR #7240)

Respond to Bohan-J's review:

- Scalar value input could not be typed into: Base UI's menu popup stops
  every printable key on the role="menu" element, which bubbled up from the
  input and swallowed characters. The input's onKeyDown now stops
  propagation for every key except Escape/Tab (Enter still commits and also
  stops propagation so it cannot activate the highlighted "No value" item).
- Scalar facet branches were unbounded: text/url/date/number grouped by
  distinct value with no LIMIT, but the menu only reads the "__none__"
  bucket. They now compute just the "__set__"/"__none__" buckets.
- New UI surface lacked tests: added issues-header.property-filter.test.tsx
  driving the real Base UI menu, covering typing, Enter/blur commit, the
  value <-> "No value" exclusion, and the blur-vs-click ordering.
- Draft staleness: sync the draft to the committed scalar value via an
  effect (keying the input did not reliably reset it), so an external clear
  cannot be written back by unchecking "No value".
- Make onSetValues required; note that isFilterablePropertyType stays an
  explicit enumeration so future property types opt into filtering.

* fix(issues): compose scalar value and No-value filters OR-style

Review round 2 on PR #7240: unchecking "No value" dropped a committed
scalar value. The draft round-trip engineered for mutual exclusion was
the root cause — checking No-value changed the committed set, the draft
effect wiped the input, and unchecking read an empty draft.

Scalars now treat "No value" as a plain member of the same OR-set as
every other property type:

- Checking/unchecking toggles membership only and never touches the
  committed value member, so no restore-on-uncheck machinery is needed.
- Committing a value replaces just the value member and preserves
  existing No-value membership.
- The string branch of issueMatchesPropertyFilters skips the sentinel
  when matching stored values, so a literal "__none__" text value cannot
  satisfy a No-value filter (agreeing with the server's key-absence
  predicate).

Component tests cover the exact reviewed sequence (commit -> check ->
uncheck restores the value) and the uncommitted-draft path.

---------

Co-authored-by: deo <deo@deo的macbook\032pro.tyaio>
2026-08-28 15:56:34 +08:00
Deoanddeo 66af64b9a6 feat(issues): support filtering custom properties by "no value" (#7156)
Add a "No value" (__none__) option to the custom-property filter for
checkbox/select/multi_select/actor properties. Selecting it matches issues
where the property is unset — the inverse of picking a value — so teams can
express "everything that is NOT parked", e.g. exclude a `挂起` Hold checkbox.

Backend:
- parsePropertiesFilterParam compiles "__none__" into a
  {"__none__": "<defId>"} marker; propertiesFilterPredicate renders it as a
  key-absence disjunction (NOT (i.properties ? $n)) instead of jsonb
  containment. The static ListOpenIssues unroll recognizes the same marker.
- Property table facets report an unset "__none__" bucket so the filter menu
  shows a real count for the option.

Frontend:
- issueMatchesPropertyFilters matches unset issues when "__none__" is selected
  (OR-ed with other values within the definition, AND-ed across definitions).
- PropertyFilterOptions offers a "No value" option for every property type;
  filter chips render it via pickers.custom_property.no_value_label.
- i18n keys added for en/zh-Hans/ja/ko.

Co-authored-by: deo <deo@deo的macbook\032pro.tyaio>
2026-08-19 17:03:06 +08:00
9a30c51dac MUL-6286: feat(properties): actor / multi_actor custom property types (members) (#7075)
* feat(properties): add actor / multi_actor custom property types (MUL-6286)

Custom issue properties gain their first reference type: `actor` (single) and
`multi_actor` (multiple), holding workspace members and agents.

Value shape is a prefixed string — "member:<user_id>" / "agent:<agent_id>",
multi being an array of them. Keeping it a plain string rather than an object
means the existing @> containment filter, the jsonb_path_ops GIN index and the
client value schema all keep working unchanged; an object-shaped value would
have been silently dropped by the client preprocess that guards against
unknown value shapes.

Scope decisions:

- Squads are out. The assignee pair accepts member/agent/squad, but a squad is
  a routing target rather than a person. The stored form carries the kind, so
  widening later needs no migration and no new type.
- Referencing an agent is NOT an assignment: nothing is enqueued, routed or
  notified. The picker therefore does not gate agents on runtime binding or
  invoke permission the way the assignee picker does — it gates on visibility,
  matching the server.
- Agent identity still needs protecting, so writes and facet reads both run
  through accessibleAgentIDs: a private agent never becomes referenceable or
  discoverable by id.
- multi_actor dedupes but preserves insertion order instead of canonicalizing
  like multi_select. There is no config order to sort against, and sorting by
  id would reshuffle the avatar row on every edit. @> containment is
  order-insensitive, so filtering is unaffected.
- Actor properties are filterable (including "= me", with the signed-in member
  sorted first) but not sortable or groupable — the same position multi_select
  already holds.

Both types ship together because a property's type is immutable: shipping
single-only would have forced anyone wanting multi to recreate the property
and re-enter its values.

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

* refactor(properties): narrow actor properties to members only (MUL-6286)

Scope change after review: `actor` / `multi_actor` hold workspace members
only. Agents are out — a passive reference had to answer agent-visibility
questions (private / non-allow-listed agents must not become discoverable by
id) and carry a "referencing is not assigning" caveat everywhere, all for a
use case nobody has asked for. The original request was for members.

The envelope is unchanged on purpose, so this stays a one-line widening later
rather than a migration: the types are still named `actor` / `multi_actor`,
the stored value is still the kind-prefixed "member:<user_id>", and the
migration still allows both type names. Adding "agent" back means appending to
`actorPropertyKinds` — existing definitions would gain the kind in place, with
no new property type and no value rewrite.

What this removes:

- The agent branch of `resolveActorRefs`, including the accessibleAgentIDs
  visibility gate and the archived-agent check.
- `filterAccessibleActorFacetValues` entirely. Facet keys are now all
  `member:<uuid>`, and workspace membership is already visible to every
  member, so there is nothing left to gate.
- The agent section of the actor picker, and agents from the filter dropdown's
  candidate list.
- The "reference, not an assignment" framing from the skill and product docs —
  that distinction only existed because agents were allowed, and keeping it
  would now confuse rather than clarify.

`actorPropertyKinds` carries the note that any future kind which is not
universally visible to every member must restore the visibility gate on both
the write path and the facet read path.

Tests updated to match: agent refs are now rejected at the kind check, and the
private-agent visibility test is replaced by TestIssueActorPropertyFacets,
which covers the facet path that backs the "= me" filter.

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

* fix(properties): address actor-property review findings (MUL-6286)

Four defects from review, plus one pre-existing bug found next to them.

1. Editing a multi_actor value silently deleted kinds the client did not
   recognise. The picker rebuilt the array from `actorRefsFromValue`, which
   filters unknown kinds — so once the backend widens actorPropertyKinds, an
   installed desktop build would drop every agent reference the moment the
   user toggled a member. The edit path now round-trips the RAW string list
   via the new `actorRefValuesFromValue`, and the toggle is extracted to
   `toggleActorRefValue` so the guarantee is directly testable.

2. Actor ids were stored exactly as received. `uuid.Parse` also accepts
   uppercase, braces and the urn: form, and every consumer (member directory
   lookup, "= me" filter, @> containment) compares reference strings exactly —
   so `member:<UPPERCASE-UUID>` passed validation and then rendered as Unknown
   and matched no filter. `parseActorRef` now stores the canonical form.

3. The CLI, docs and skill all promised member email as a way to address a
   value, but `resolveAssignee` only matched id and name. Email is now an
   exact-match alias ranked with id matches (it is as unambiguous as an id),
   so the documented behaviour is real. This also makes `--assignee` accept an
   email, which is a strict improvement.

4. An active actor-property filter rendered as a bare count. The chips bar
   loads each directory lazily and gated the member directory on
   assignee/creator filters only, then looked names up in `config.options`,
   which actor properties do not have. It now detects actor-typed filters,
   loads the directory, and renders names plus an avatar stack.

Found while fixing 4: the chips bar keyed its actor-name map by the
membership row id (`m.id`) while every actor filter value carries `user_id`,
so member names never resolved in the assignee and creator chips either. One
token, same expression, fixed here rather than left next to the new code.

Also drops two comments still describing the member/agent scope.

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

* fix(properties): stop a single actor value overwriting an unreadable ref (MUL-6286)

Round-two review finding. The previous fix only protected `multi_actor`:
its toggle round-trips raw strings, so a reference kind this build cannot
parse survives an edit. Single `actor` had no such protection.

Once the backend widens actorPropertyKinds, an older client reading
`agent:<id>` in a single actor property would filter it out, render the field
as empty, and then — when the user "filled in the empty field" — replace it
outright. The user sees themselves setting a blank field; what actually
happens is a value they were never shown gets deleted.

The editor now degrades to read-only when a single actor value holds a
reference it cannot parse, and the display says so instead of showing the
empty-state label. Clear is still offered, so a genuinely stale value can be
removed deliberately. `multi_actor` stays editable — its toggle is already
non-destructive.

The read-only decision moved into an exported `isCustomPropertyReadOnly`, so
all three reasons it can fire (archived, unknown type, unreadable actor ref)
are covered by tests rather than being inline in the component.

Also closes the two non-blocking gaps from the same review:

- The "email outranks a name substring match" CLI test asserted a ranking its
  fixture could not actually exercise — no candidate's name contained the
  email. Added an agent named "Mailer for [email protected]", so the test now
  fails if email stops being ranked with id matches.
- Extracted `buildChipActorNames` and asserted a member resolves by `user_id`
  and NOT by the membership row id — the pre-existing bug fixed in the last
  commit now has a test that would catch its return.

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

* chore(migrations): renumber actor-types migration to 341 (MUL-6286)

332 was taken by 332_issue_status on main while this PR was open; the
migration lint test caught the collision in CI.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 19:30:03 +08:00
13b06f038e fix(issues): count agents working in the surface, not the workspace (MUL-5525) (#6191)
* fix(issues): count agents working in the surface, not the workspace (MUL-5525)

The "N agents working" chip ran its own workspace-wide
`/api/working-agents` read while the list it filters came from the
surface's own compiled query. Two definitions of the same question, so on
a project page the chip could advertise agents working nowhere near that
project and then open an empty list. Every other narrowing the list knows
about — status, priority, assignee, creator, label, custom property,
date, sub-issue display, the /issues Members/Agents tabs — was invisible
to the count for the same reason. Only /my-issues (relation) and the
issue-detail sub-issue chip (parent) were narrowed, because those were
the two cases the endpoint had grown parameters for.

Rather than add a `project_id` parameter and leave the next dimension to
be discovered the same way, the count now comes from a `working_agents`
facet on the existing issue-table facets endpoint: same scope, same
filters, same compiled WHERE clause the rows come from, joined to running
issue tasks and grouped by agent. Correct-by-construction instead of
correct-by-keeping-two-lists-in-sync.

- Facet is disjunctive like every other one: it drops `working_issue_ids`
  / `working_only`, so the answer is identical whether the filter is on or
  off and the number does not move when you click the chip.
- Facet keys are agent ids, so they pass the same visibility gate as the
  other workspace-wide agent aggregations — a private or non-allow-listed
  agent is not disclosed by id, count, or presence.
- Gantt keeps a client-side count: its canvas projection (scheduled +
  dated + showCompleted) cannot be expressed in the Table query spec, so
  it counts the agents holding canvas rows instead.
- The chip is now presentational; `undefined` renders the existing
  indeterminate label rather than a zero it cannot stand behind.
- Removes the MUL-4884 `workingScopeIssues` plumbing, dead since the count
  moved to the endpoint in MUL-5200, keeping only the Gantt branch that
  still has a real consumer.

Also fixes the empty state that bug dropped you into: a filtered-empty
surface claimed "No issues linked — create one" while 41 issues sat behind
the filter. Shared filtered-empty state now precedes each surface's own
copy and offers to clear exactly the filters it blames.

Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors),
go test ./internal/handler (new facet tests cover project scope, status
and sub-issue narrowing, filter-independence, and the access gate).

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

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

* fix(issues): keep the working-agents unknown state unknown (MUL-5525)

The chip correctly refused to print a number for an unresolved projection,
then handed the hover card `agents ?? []` — so hovering an indeterminate
chip read "No agents working right now". That is the same unearned claim
this issue is about, made by the one surface with room to spell it out:
the label said "—" while the body next to it asserted zero.

- `WorkingAgentsHoverContent` takes `readonly WorkingAgentSummary[] |
  undefined` and distinguishes all three states: `undefined` renders new
  `agent_activity.unknown_hover` copy, `[]` keeps the empty sentence, a
  non-empty list keeps the roster. The chip passes its projection through
  untouched.
- The colour tier had the same collapse: unknown wore the neutral tier
  WITH muted text, which is exactly the "nothing is happening here" tier a
  known zero wears. `chipAppearance` now takes a `ChipActivity`
  ("unknown" | "none" | "some") instead of a boolean, so the three cases
  cannot be written as two, and unknown stays neutral but undimmed.
- The sub-issues chip is unaffected: it passes a resolved array and
  renders nothing at zero, so it never claimed anything either way.

Regression tests cover the hover path specifically — reverting either
downgrade fails "does not let the hover body downgrade an unresolved
projection to zero", "does not dim the chip while the projection is
unresolved", and the chipAppearance unknown case (verified by reverting).
`WorkingAgentsHoverContent` also gets direct unknown / empty / roster
tests, and `chipActivity` one for the three-way split.

Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors).

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 13:04:42 +08:00
8065cead85 MUL-5198: Restore server-backed issue table grouping (MUL-5100) (#5817)
* revert(issues): restore server-backed table grouping

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

* test(skills): stabilize import completion coverage

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-23 12:03:27 +08:00
LinYushen 4dc47ef113 Revert "MUL-5100: Move issue table grouping to the server" (#5777)
This reverts commit d43e500ff6.
2026-07-22 18:06:37 +08:00
Jiayuan Zhang d43e500ff6 MUL-5100: Move issue table grouping to the server
Merge approved after review; CI checks are green.
2026-07-22 14:36:51 +08:00