Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli.ja.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ CLI の設定ファイルには、あなたとして Multica にアクセスで
| | `status <id> <status>` | プロジェクトのステータスを変更 | |
| | `resource list/add/update/remove` | プロジェクトの追加リソースを管理 | `--type`、`--url`、`--local-path`、`--daemon-id`、`--execution-mode`(ローカルディレクトリの `in_place` / `worktree`) |
| `label` | `list/get/create/update/delete` | ワークスペースのラベルを管理 | `list`: `--resource-type`(`issue` または `skill`)、`--full-id`;`create`: `--name`、`--color`、`--resource-type`(`issue` または `skill`)、`--description` |
| `property` | `list/get/create/update/archive/unarchive` | ワークスペースのカスタムプロパティを管理 | `create`: `--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`)、`--option`(繰り返し可、select 系のみ);`list`: `--include-archived`;タイプは作成後に変更できません |
| `property` | `list/get/create/update/archive/unarchive` | ワークスペースのカスタムプロパティを管理 | `create`: `--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`、`multi_text`、`multi_url`)、`--option`(繰り返し可、select 系のみ);`list`: `--include-archived`;タイプは作成後に変更できません |
| `agent` | `list/get/create/update/archive/restore` | エージェントを管理 | `--name`、`--runtime-id`(`create` では必須)、`--instructions`、`--conversation-starters`、`--model`、`--thinking-level`、`--mcp-config`、`--permission-mode`、`--max-concurrent-tasks` |
| | `copy <agent-id>` | 新しいエージェントとして複製。元のエージェントは変更されません | `--name`(デフォルトは元の名前 + ` (copy)`)、`--runtime-id`(別のランタイムへ複製する場合は `--model` の同時指定が必須)、`--no-skills`;`custom_env`、`mcp_config`、`runtime_config` などの機密設定は複製されないため、`create` と同じフラグで再指定してください |
| | `tasks <id>` | エージェントの実行を表示 | |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ CLI 설정 파일에는 사용자를 대신해 Multica에 접근할 수 있는 t
| | `status <id> <status>` | 프로젝트 상태 변경 | |
| | `resource list/add/update/remove` | 프로젝트 추가 리소스 관리 | `--type`, `--url`, `--local-path`, `--daemon-id`, `--execution-mode`(로컬 디렉터리의 `in_place` / `worktree`) |
| `label` | `list/get/create/update/delete` | 워크스페이스 라벨 관리 | `list`: `--resource-type`(`issue` 또는 `skill`), `--full-id`; `create`: `--name`, `--color`, `--resource-type`(`issue` 또는 `skill`), `--description` |
| `property` | `list/get/create/update/archive/unarchive` | 워크스페이스 사용자 지정 속성 관리 | `create`: `--name`, `--type`(`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`), `--option`(반복 가능, select 유형만). `list`: `--include-archived`. 유형은 생성 후 변경 불가 |
| `property` | `list/get/create/update/archive/unarchive` | 워크스페이스 사용자 지정 속성 관리 | `create`: `--name`, `--type`(`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`, `multi_text`, `multi_url`), `--option`(반복 가능, select 유형만). `list`: `--include-archived`. 유형은 생성 후 변경 불가 |
| `agent` | `list/get/create/update/archive/restore` | 에이전트 관리 | `--name`, `--runtime-id`(`create`에서 필수), `--instructions`, `--conversation-starters`, `--model`, `--thinking-level`, `--mcp-config`, `--permission-mode`, `--max-concurrent-tasks` |
| | `copy <agent-id>` | 원래 에이전트에 영향 없이 새 에이전트로 복사 | `--name`(기본값은 원래 이름 + ` (copy)`), `--runtime-id`(다른 런타임으로 복사할 때 `--model`도 필수), `--no-skills`. `custom_env`, `mcp_config`, `runtime_config` 같은 기밀 설정은 복사되지 않으며 `create`와 같은 flag로 다시 제공해야 함 |
| | `tasks <id>` | 에이전트 실행 확인 | |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ The tables below cover every current top-level command, grouped the way the CLI
| | `status <id> <status>` | Change project status | |
| | `resource list/add/update/remove` | Manage project resources | `--type`, `--url`, `--local-path`, `--daemon-id`, `--execution-mode` (`in_place` / `worktree` for a local directory) |
| `label` | `list/get/create/update/delete` | Manage workspace labels | `list`: `--resource-type` (`issue` or `skill`), `--full-id`; `create`: `--name`, `--color`, `--resource-type` (`issue` or `skill`), `--description` |
| `property` | `list/get/create/update/archive/unarchive` | Manage workspace custom properties | `create`: `--name`, `--type` (`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`), `--option` (repeatable, select types only); `list`: `--include-archived`; the type cannot be changed after creation |
| `property` | `list/get/create/update/archive/unarchive` | Manage workspace custom properties | `create`: `--name`, `--type` (`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`, `multi_text`, `multi_url`), `--option` (repeatable, select types only); `list`: `--include-archived`; the type cannot be changed after creation |
| `agent` | `list/get/create/update/archive/restore` | Manage agents | `--name`, `--runtime-id` (required for `create`), `--instructions`, `--conversation-starters`, `--model`, `--thinking-level`, `--mcp-config`, `--permission-mode`, `--max-concurrent-tasks` |
| | `copy <agent-id>` | Copy into a new agent; the original is untouched | `--name` (defaults to the original name plus ` (copy)`), `--runtime-id` (copying to another runtime also requires `--model`), `--no-skills`; secret configuration such as `custom_env`, `mcp_config`, and `runtime_config` is not copied — re-provide it with the same flags as `create` |
| | `tasks <id>` | View an agent's runs | |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli.zh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ CLI 配置文件包含可代表你访问 Multica 的令牌。不要提交到仓
| | `status <id> <status>` | 修改项目状态 | |
| | `resource list/add/update/remove` | 管理项目附加资源 | `--type`、`--url`、`--local-path`、`--daemon-id`、`--execution-mode`(本地目录的 `in_place` / `worktree`) |
| `label` | `list/get/create/update/delete` | 管理工作区标签 | `list`:`--resource-type`(`issue` 或 `skill`)、`--full-id`;`create`:`--name`、`--color`、`--resource-type`(`issue` 或 `skill`)、`--description` |
| `property` | `list/get/create/update/archive/unarchive` | 管理工作区自定义属性 | `create`:`--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`)、`--option`(可重复,仅 select 类型);`list`:`--include-archived`;类型创建后不可修改 |
| `property` | `list/get/create/update/archive/unarchive` | 管理工作区自定义属性 | `create`:`--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`、`multi_text`、`multi_url`)、`--option`(可重复,仅 select 类型);`list`:`--include-archived`;类型创建后不可修改 |
| `agent` | `list/get/create/update/archive/restore` | 管理智能体 | `--name`、`--runtime-id`(`create` 必填)、`--instructions`、`--conversation-starters`、`--model`、`--thinking-level`、`--mcp-config`、`--permission-mode`、`--max-concurrent-tasks` |
| | `copy <agent-id>` | 复制为新智能体,原智能体不受影响 | `--name`(默认为原名加 ` (copy)`)、`--runtime-id`(复制到其他运行时时必须同时指定 `--model`)、`--no-skills`;`custom_env`、`mcp_config`、`runtime_config` 等机密配置不会复制,需用与 `create` 相同的 flag 重新提供 |
| | `tasks <id>` | 查看智能体的运行 | |
Expand Down
86 changes: 85 additions & 1 deletion packages/core/issues/queries.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";

import { setApiInstance } from "../api";
Expand All @@ -8,21 +8,27 @@ import type {
Issue,
IssueTableRowsRequest,
IssueTableRowsResponse,
ListIssuesCache,
ListIssuesParams,
ListIssuesResponse,
} from "../types";
import { pruneDeletedIssueFromListCaches } from "./delete-cache";
import {
CHILDREN_BY_PARENTS_CHUNK_SIZE,
ISSUE_PAGE_SIZE,
PROJECT_GANTT_MAX_ISSUES,
PROJECT_GANTT_PAGE_LIMIT,
childrenByParentsOptions,
childIssuesOptions,
flattenIssueBuckets,
issueIdentifierOptions,
issueKeys,
issueListOptions,
issueTableRowPageOptions,
projectGanttIssuesOptions,
sourceContextPreviewOptions,
} from "./queries";
import { onIssueUpdated } from "./ws-updaters";

const WS_ID = "ws-1";
const PROJECT_ID = "project-1";
Expand Down Expand Up @@ -315,6 +321,84 @@ describe("issueTableRowPageOptions", () => {
});
});

describe("issueListOptions", () => {
const todoIssue = makeIssue(1, { status: "todo", status_category: "unstarted" });
const startedIssue = makeIssue(2, { status: "in_progress", status_category: "started" });
const doneIssue = makeIssue(3, { status: "done", status_category: "done" });
// A custom status the server did not resolve to a category.
const customIssue = makeIssue(4, { status: "qa" });

let qc: QueryClient;
let listIssues: Mock<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>;

beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Like the real client, the fake ignores `status_category`: every call
// returns the same first page.
listIssues = vi.fn<(params?: ListIssuesParams) => Promise<ListIssuesResponse>>().mockResolvedValue({
issues: [todoIssue, startedIssue, doneIssue, customIssue],
total: 120,
});
installFakeApi(listIssues);
});

afterEach(() => {
qc.clear();
});

async function fetchList() {
const options = issueListOptions(WS_ID);
await qc.fetchQuery(options);
return () => qc.getQueryData<ListIssuesCache>(options.queryKey)!;
}

it("sends one list request per fetch", async () => {
await fetchList();

expect(listIssues).toHaveBeenCalledTimes(1);
expect(listIssues).toHaveBeenCalledWith({ limit: ISSUE_PAGE_SIZE, offset: 0 });
});

it("puts each issue in its own category's bucket only", async () => {
const cache = (await fetchList())();

expect(cache.byStatus).toEqual({
unstarted: { issues: [todoIssue, customIssue], total: 2 },
started: { issues: [startedIssue], total: 1 },
done: { issues: [doneIssue], total: 1 },
closed: { issues: [], total: 0 },
});
expect(flattenIssueBuckets(cache).map((issue) => issue.id)).toEqual([
todoIssue.id,
customIssue.id,
startedIssue.id,
doneIssue.id,
]);
});

it("drops a deleted issue from every bucket", async () => {
const readCache = await fetchList();

pruneDeletedIssueFromListCaches(qc, WS_ID, todoIssue.id);

expect(flattenIssueBuckets(readCache()).map((issue) => issue.id)).not.toContain(todoIssue.id);
});

it("keeps only the updated copy of an issue moved to done", async () => {
const readCache = await fetchList();

onIssueUpdated(
qc,
WS_ID,
{ id: todoIssue.id, status: "done", status_category: "done" },
{ statusChanged: true },
);

const copies = flattenIssueBuckets(readCache()).filter((issue) => issue.id === todoIssue.id);
expect(copies).toEqual([{ ...todoIssue, status: "done", status_category: "done" }]);
});
});

describe("projectGanttIssuesOptions", () => {
let qc: QueryClient;

Expand Down
26 changes: 15 additions & 11 deletions packages/core/issues/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ListIssuesCache,
} from "../types";
import { ALL_STATUSES } from "./config";
import { issueColumnCategory } from "./status-category";

export function issueTasksOptions(issueId: string) {
return queryOptions({
Expand Down Expand Up @@ -242,7 +243,7 @@ export type AssigneeGroupedIssuesFilter = Omit<
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
>;

/** Page size per status column. */
/** Size of the issue list's first page, all categories together. */
export const ISSUE_PAGE_SIZE = 50;

/**
Expand All @@ -267,17 +268,20 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}

/**
* One request for the list's first page, grouped into the category buckets
* here. Every reader of this cache looks an issue up by id and none pages a
* bucket, so one shared window serves them all. Each bucket's `total` is its
* own row count. A custom status the server did not resolve falls back to
* `issueColumnCategory`'s bucket, so no row is dropped.
*/
async function fetchFirstPages(filter: MyIssuesFilter = {}, sort?: IssueSortParam): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_CATEGORIES.map((category) =>
api.listIssues({ status_category: category, limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter }),
),
);
const res = await api.listIssues({ limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter });
const byStatus: ListIssuesCache["byStatus"] = {};
PAGINATED_CATEGORIES.forEach((status: IssueStatusCategory, i: number) => {
const res = responses[i]!;
byStatus[status] = { issues: res.issues, total: res.total };
});
for (const category of PAGINATED_CATEGORIES) {
const issues = res.issues.filter((issue) => issueColumnCategory(issue) === category);
byStatus[category] = { issues, total: issues.length };
}
return { byStatus };
}

Expand Down Expand Up @@ -361,7 +365,7 @@ export function issueTableFacetsOptions(
* `Issue[]` for consumers. Mutations and ws-updaters must use
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
*
* Fetches the first page of each paginated status in parallel.
* Fetches the first page in one request and buckets it by category.
*/
export function issueListOptions(wsId: string, sort?: IssueSortParam) {
return queryOptions({
Expand Down
11 changes: 6 additions & 5 deletions packages/core/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ export interface ListIssuesParams {
/**
* Filter by lifecycle category rather than by exact key, so one bucket holds
* all concrete and custom statuses in that phase. Task views use exact
* status keys for their columns instead.
* status keys for their columns instead. The web client's
* `ApiClient.listIssues` does not send it.
*/
status_category?: IssueStatusCategory;
/** Multi-value form of `status_category`. OR within the field. */
Expand Down Expand Up @@ -512,16 +513,16 @@ export interface WorkingAgentSummary {
running_task_count: number;
}

/** Per-status bucket in the paginated issue cache. `total` is the server count (all pages), not the length of `issues`. */
/** Per-category bucket in the issue list cache. The list's fetch sets `total` to the bucket's row count. */
export interface IssueStatusBucket {
issues: Issue[];
total: number;
}

/**
* Frontend cache shape for the issue list. Data is bucketed by status so
* each column can paginate independently. Assembled from per-status
* `api.listIssues` responses by the query functions in `issues/queries.ts`.
* Frontend cache shape for the issue list. Data is bucketed by status
* category. Assembled from one `api.listIssues` response by the query
* functions in `issues/queries.ts`.
*/
export interface ListIssuesCache {
/** Bucketed by status CATEGORY — see PAGINATED_CATEGORIES. (MUL-6243) */
Expand Down
4 changes: 2 additions & 2 deletions packages/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread, Arc
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, CommentSupplementReceipt, CommentSupplementStatus, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, CommentTriggerOutcome, CommentTriggerStatus, Reaction } from "./comment";
export type { Label, LabelResourceType, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse, ResourceLabelsResponse } from "./label";
export type { IssueProperty, IssuePropertyType, ScalarIssuePropertyType, IssuePropertyOption, IssuePropertyConfig, IssuePropertyValue, IssuePropertyValues, CreatePropertyRequest, UpdatePropertyRequest, ListPropertiesResponse, IssuePropertiesResponse, IssuePropertyActorKind, IssuePropertyActorRef, PropertyFilterOp, PropertyOperatorFilter, PropertyFilterValue } from "./property";
export { ISSUE_PROPERTY_TYPES, isKnownPropertyType, ISSUE_PROPERTY_ACTOR_KINDS, MAX_ISSUE_PROPERTY_ACTOR_VALUES, isActorPropertyType, isFilterablePropertyType, isScalarPropertyType, formatActorRef, parseActorRef, actorRefsFromValue, actorRefValuesFromValue, hasUnknownActorRef, isPropertyOperatorFilter, isKnownPropertyFilterOp, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, PROPERTY_FILTER_OPS_BY_TYPE } from "./property";
export type { IssueProperty, IssuePropertyType, ScalarIssuePropertyType, ListIssuePropertyType, IssuePropertyOption, IssuePropertyConfig, IssuePropertyValue, IssuePropertyValues, CreatePropertyRequest, UpdatePropertyRequest, ListPropertiesResponse, IssuePropertiesResponse, IssuePropertyActorKind, IssuePropertyActorRef, PropertyFilterOp, PropertyOperatorFilter, PropertyFilterValue } from "./property";
export { ISSUE_PROPERTY_TYPES, isKnownPropertyType, ISSUE_PROPERTY_ACTOR_KINDS, MAX_ISSUE_PROPERTY_ACTOR_VALUES, isActorPropertyType, isFilterablePropertyType, isScalarPropertyType, isListPropertyType, formatActorRef, parseActorRef, actorRefsFromValue, actorRefValuesFromValue, hasUnknownActorRef, isPropertyOperatorFilter, isKnownPropertyFilterOp, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, PROPERTY_FILTER_OPS_BY_TYPE } from "./property";
export type {
QuickAction,
QuickActionVisibility,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/types/property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isActorPropertyType,
isFilterablePropertyType,
isKnownPropertyType,
isListPropertyType,
isScalarPropertyType,
parseActorRef,
} from "./property";
Expand Down Expand Up @@ -48,6 +49,8 @@ describe("isFilterablePropertyType", () => {
"url",
"actor",
"multi_actor",
"multi_text",
"multi_url",
]) {
expect(isFilterablePropertyType(type)).toBe(true);
}
Expand All @@ -69,6 +72,16 @@ describe("isScalarPropertyType", () => {
});
});

describe("isListPropertyType", () => {
it("covers both list types and nothing else", () => {
expect(isListPropertyType("multi_text")).toBe(true);
expect(isListPropertyType("multi_url")).toBe(true);
expect(isListPropertyType("text")).toBe(false);
expect(isListPropertyType("url")).toBe(false);
expect(isListPropertyType("multi_select")).toBe(false);
});
});

describe("parseActorRef", () => {
it("parses member references", () => {
expect(parseActorRef(`member:${MEMBER}`)).toEqual({ kind: "member", id: MEMBER });
Expand Down
Loading
Loading