FN-9044: group workspace tasks by acquired worktrees

Show workspace-mode task cards in their own worktree group instead of Unassigned.

- Classify workspace worktree groups with stable identifiers and repository counts.
- Render localized workspace headers and appropriate group icons.
- Cover workspace grouping across utilities, columns, lanes, and group headers.
- Document the board behavior and add a patch changeset.

Files changed:
 .changeset/fn-9044-workspace-worktree-grouping.md  |  7 ++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/components/Column.tsx       |  4 +-
 .../dashboard/app/components/WorktreeGroup.tsx     | 20 ++++--
 .../app/components/__tests__/Column.test.tsx       | 32 ++++++++-
 .../app/components/__tests__/Lane.test.tsx         | 24 ++++++-
 .../__tests__/worktree-grouping-workspace.test.tsx | 66 +++++++++++++++++
 .../app/utils/__tests__/worktreeGrouping.test.ts   | 82 ++++++++++++++++++++++
 packages/dashboard/app/utils/worktreeGrouping.ts   | 37 ++++++++--
 packages/i18n/locales/en/app.json                  |  4 +-
 packages/i18n/src/resources.d.ts                   |  4 +-
 11 files changed, 267 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-9044

Fusion-Task-Lineage: d342d4fb-9fab-4efd-813f-2a3091d7c414

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 20:50:55 -07:00
parent 66b1c046c5
commit 0188d9427b
11 changed files with 267 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Workspace-mode tasks now show their sub-repo worktrees on the board instead of Unassigned.
category: fix
dev: groupByWorktree groups expose stable id, kind, and workspace repoCount fields.

View File

@@ -583,7 +583,7 @@ For the stored settings shape, see [`customProviders` in the Settings Reference]
Open **Settings → Worktrees** to maintain **Files to copy into new worktrees**. Add editable rows for repository-root-relative files such as `.env`, use **Browse** to select a project file, remove rows you no longer want, then Save. Fusion trims blank rows and de-duplicates paths before persisting. During task startup, configured regular files are copied into fresh or pooled task worktrees before the worktree init command and task execution begin; existing/resumed worktrees are not overwritten. Missing files, directories, absolute paths, traversal entries, and unreadable sources are skipped with non-fatal diagnostics and without logging file contents. See [`worktreeCopyFiles` in the Settings Reference](./settings-reference.md#project-settings) for the stored setting shape. Open **Settings → Worktrees** to maintain **Files to copy into new worktrees**. Add editable rows for repository-root-relative files such as `.env`, use **Browse** to select a project file, remove rows you no longer want, then Save. Fusion trims blank rows and de-duplicates paths before persisting. During task startup, configured regular files are copied into fresh or pooled task worktrees before the worktree init command and task execution begin; existing/resumed worktrees are not overwritten. Missing files, directories, absolute paths, traversal entries, and unreadable sources are skipped with non-fatal diagnostics and without logging file contents. See [`worktreeCopyFiles` in the Settings Reference](./settings-reference.md#project-settings) for the stored setting shape.
Use **Show worktree grouping on the board** in **Settings → Worktrees** when you want WIP/processing columns to always show worktree names and group cards by worktree. With the toggle off, Fusion preserves the legacy default: only the non-workflow `in-progress` column is grouped, and workflow-mode processing columns stay as plain cards. Use **Show worktree grouping on the board** in **Settings → Worktrees** when you want WIP/processing columns to always show worktree names and group cards by worktree. Workspace-mode tasks appear in their own group labeled with the acquired worktree name and repository count; only tasks with no acquired worktree remain **Unassigned**. With the toggle off, Fusion preserves the legacy default: only the non-workflow `in-progress` column is grouped, and workflow-mode processing columns stay as plain cards.
## Planning Mode ## Planning Mode

View File

@@ -1118,7 +1118,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
) : ( ) : (
worktreeGroups.map((group) => ( worktreeGroups.map((group) => (
<WorktreeGroup <WorktreeGroup
key={group.label} key={group.id}
kind={group.kind}
repoCount={group.repoCount}
label={group.label} label={group.label}
activeTasks={group.activeTasks} activeTasks={group.activeTasks}
queuedTasks={group.queuedTasks} queuedTasks={group.queuedTasks}

View File

@@ -1,6 +1,7 @@
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Task, TaskDetail, MergeResult, GithubIssueAction, ColumnId } from "@fusion/core"; import type { Task, TaskDetail, MergeResult, GithubIssueAction, ColumnId } from "@fusion/core";
import type { WorktreeGroupData } from "../utils/worktreeGrouping";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/duplicates/near-duplicate-canonical"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/duplicates/near-duplicate-canonical";
import { ClipboardList, GitBranch } from "lucide-react"; import { ClipboardList, GitBranch } from "lucide-react";
import { TaskCard } from "./TaskCard"; import { TaskCard } from "./TaskCard";
@@ -10,7 +11,9 @@ import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
interface WorktreeGroupProps { interface WorktreeGroupProps {
kind: WorktreeGroupData["kind"];
label: string; label: string;
repoCount?: number;
activeTasks: Task[]; activeTasks: Task[];
queuedTasks: Task[]; queuedTasks: Task[];
allTasks?: Task[]; allTasks?: Task[];
@@ -67,7 +70,9 @@ interface WorktreeGroupProps {
} }
function WorktreeGroupComponent({ function WorktreeGroupComponent({
kind,
label, label,
repoCount,
activeTasks, activeTasks,
queuedTasks, queuedTasks,
allTasks, allTasks,
@@ -104,8 +109,15 @@ function WorktreeGroupComponent({
taskContextMenuColumnsByTaskId, taskContextMenuColumnsByTaskId,
}: WorktreeGroupProps) { }: WorktreeGroupProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const upNextLabel = t("worktree.upNext", "Up Next"); /*
const unassignedLabel = t("worktree.unassigned", "Unassigned"); FNXC:Workspace 2026-08-15-03:35:
Group labels originate in a pure utility as English display values, while this component
translates headers. Comparing those strings selected the wrong icon in non-English locales;
the stable group kind is the locale-independent header contract.
*/
const headerLabel = kind === "workspace"
? t("worktree.workspaceRepos", "{{label}} · {{count}} repos", { label, count: repoCount ?? 0 })
: label;
const resolveNearDuplicateCanonicalInactive = (task: Task): boolean | undefined => { const resolveNearDuplicateCanonicalInactive = (task: Task): boolean | undefined => {
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf; const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined; if (typeof nearDuplicateOf !== "string" || !allTasks) return undefined;
@@ -127,9 +139,9 @@ function WorktreeGroupComponent({
<div className="worktree-group"> <div className="worktree-group">
<div className="worktree-group-header"> <div className="worktree-group-header">
<span className="worktree-icon"> <span className="worktree-icon">
{label === upNextLabel || label === unassignedLabel ? <ClipboardList size={14} /> : <GitBranch size={14} />} {kind === "unassigned" || kind === "up-next" ? <ClipboardList size={14} /> : <GitBranch size={14} />}
</span> </span>
<span className="worktree-label">{label}</span> <span className="worktree-label">{headerLabel}</span>
</div> </div>
{activeTasks.map((task) => ( {activeTasks.map((task) => (
<TaskCard <TaskCard

View File

@@ -25,8 +25,8 @@ vi.mock("../TaskCard", () => ({
}), }),
})); }));
vi.mock("../WorktreeGroup", () => ({ vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: ({ label, activeTasks, queuedTasks }: { label: string; activeTasks: Task[]; queuedTasks: Task[] }) => ( WorktreeGroup: ({ label, kind, activeTasks, queuedTasks }: { label: string; kind: string; activeTasks: Task[]; queuedTasks: Task[] }) => (
<div data-testid="worktree-group" data-label={label} data-active-count={activeTasks.length} data-queued-count={queuedTasks.length}> <div data-testid="worktree-group" data-label={label} data-kind={kind} data-active-count={activeTasks.length} data-queued-count={queuedTasks.length}>
<span>{label}</span> <span>{label}</span>
{activeTasks.map((task) => <div key={task.id} data-testid={`group-active-${task.id}`}>{task.id}</div>)} {activeTasks.map((task) => <div key={task.id} data-testid={`group-active-${task.id}`}>{task.id}</div>)}
{queuedTasks.map((task) => <div key={task.id} data-testid={`group-queued-${task.id}`}>{task.id}</div>)} {queuedTasks.map((task) => <div key={task.id} data-testid={`group-queued-${task.id}`}>{task.id}</div>)}
@@ -567,6 +567,34 @@ describe("Column worktree grouping setting", () => {
expect(screen.queryByTestId("task-FN-003")).toBeNull(); expect(screen.queryByTestId("task-FN-003")).toBeNull();
}); });
it("passes workspace tasks to a workspace group instead of Unassigned", () => {
const workspaceTask = {
...makeTask("FN-9044"),
column: "exec" as ColumnType,
workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
},
};
render(
<Column
{...defaultProps}
column={"exec" as ColumnType}
workflowMode
columnDisplayName="Executing"
columnFlags={{ countsTowardWip: true }}
showWorktreeGrouping
tasks={[workspaceTask]}
allTasks={[workspaceTask]}
/>,
);
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-kind", "workspace");
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-label", "FN-9044");
expect(screen.queryByText("Unassigned")).toBeNull();
expect(screen.getByTestId("group-active-FN-9044")).toBeInTheDocument();
});
it("does not leave worktree shells in empty processing columns", () => { it("does not leave worktree shells in empty processing columns", () => {
const { rerender } = render( const { rerender } = render(
<Column <Column

View File

@@ -18,7 +18,13 @@ vi.mock("../TaskCard", () => ({
</div> </div>
), ),
})); }));
vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () => <div data-testid="worktree-group" /> })); vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: ({ label, kind, activeTasks }: { label: string; kind: string; activeTasks: Task[] }) => (
<div data-testid="worktree-group" data-label={label} data-kind={kind}>
{activeTasks.map((task) => <div key={task.id} data-testid={`group-active-${task.id}`}>{task.id}</div>)}
</div>
),
}));
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry-box" /> })); vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry-box" /> }));
vi.mock("../PluginSlot", () => ({ PluginSlot: () => null })); vi.mock("../PluginSlot", () => ({ PluginSlot: () => null }));
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
@@ -99,6 +105,22 @@ describe("Lane", () => {
expect(headings).not.toContain("Archived"); expect(headings).not.toContain("Archived");
}); });
it("forwards workspace tasks through Column's worktree-grouping path", () => {
const workspaceTask = mkTask({
id: "FN-9044",
column: "in-progress",
workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
},
});
render(<Lane {...baseProps()} showWorktreeGrouping tasks={[workspaceTask]} />);
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-kind", "workspace");
expect(screen.getByTestId("worktree-group")).toHaveAttribute("data-label", "FN-9044");
expect(screen.getByTestId("group-active-FN-9044")).toBeInTheDocument();
});
it("renders creation controls only in the first visible column", () => { it("renders creation controls only in the first visible column", () => {
render(<Lane {...baseProps()} onQuickCreate={vi.fn()} onNewTask={vi.fn()} />); render(<Lane {...baseProps()} onQuickCreate={vi.fn()} onNewTask={vi.fn()} />);

View File

@@ -0,0 +1,66 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { WorktreeGroup } from "../WorktreeGroup";
vi.mock("../TaskCard", () => ({
TaskCard: ({ task }: { task: Task }) => <div data-testid={`task-${task.id}`}>{task.id}</div>,
}));
vi.mock("lucide-react", () => ({
ClipboardList: () => <svg data-testid="clipboard-icon" />,
GitBranch: () => <svg data-testid="git-branch-icon" />,
}));
const workspaceTask = {
id: "FN-9044",
title: "Workspace task",
description: "",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-08-03T00:00:00.000Z",
updatedAt: "2026-08-03T00:00:00.000Z",
workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
},
} as Task;
function renderGroup(kind: "workspace" | "unassigned", task: Task, repoCount?: number) {
return render(
<WorktreeGroup
{...({
kind,
label: kind === "workspace" ? "FN-9044" : "Unassigned",
repoCount,
activeTasks: [task],
queuedTasks: [],
onOpenDetail: vi.fn(),
addToast: vi.fn(),
} as React.ComponentProps<typeof WorktreeGroup>)}
/>,
);
}
describe("WorktreeGroup workspace headers", () => {
it("renders the workspace group, not Unassigned, with a git branch icon and one task card", () => {
renderGroup("workspace", workspaceTask, 2);
expect(screen.getByText("FN-9044 · 2 repos")).toBeInTheDocument();
expect(screen.queryByText("Unassigned")).not.toBeInTheDocument();
expect(screen.getByTestId("git-branch-icon")).toBeInTheDocument();
expect(screen.queryByTestId("clipboard-icon")).not.toBeInTheDocument();
expect(screen.getAllByTestId("task-FN-9044")).toHaveLength(1);
});
it("keeps a genuinely worktree-less task in the Unassigned group", () => {
renderGroup("unassigned", { ...workspaceTask, id: "FN-unassigned", workspaceWorktrees: undefined });
expect(screen.getByText("Unassigned")).toBeInTheDocument();
expect(screen.getByTestId("clipboard-icon")).toBeInTheDocument();
expect(screen.getAllByTestId("task-FN-unassigned")).toHaveLength(1);
});
});

View File

@@ -110,6 +110,88 @@ describe("groupByWorktree", () => {
expect(groups[0].activeTasks).toEqual([unassigned]); expect(groups[0].activeTasks).toEqual([unassigned]);
}); });
it("groups workspace tasks by their acquired repo worktrees", () => {
const workspaceTask = makeTask({
id: "FN-9044",
workspaceWorktrees: {
"repo-c": { worktreePath: "/ws/repo-c/.worktrees/FN-9044", branch: "fusion/FN-9044" },
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/FN-9044" },
},
});
const groups = groupByWorktree([workspaceTask], [workspaceTask], 2);
expect(groups).toEqual([expect.objectContaining({
id: "workspace:FN-9044",
kind: "workspace",
label: "FN-9044",
repoCount: 3,
activeTasks: [workspaceTask],
})]);
expect(groups.find((group) => group.kind === "unassigned")).toBeUndefined();
});
it("uses a workspace group for a single acquired repo", () => {
const workspaceTask = makeTask({
id: "FN-9044",
workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/FN-9044" },
},
});
expect(groupByWorktree([workspaceTask], [workspaceTask], 2)[0]).toMatchObject({
kind: "workspace", repoCount: 1, label: "FN-9044",
});
});
it("keeps tasks without acquired workspace worktrees unassigned", () => {
const emptyWorkspace = makeTask({ id: "FN-empty", workspaceWorktrees: {} });
const missingWorkspace = makeTask({ id: "FN-missing", workspaceWorktrees: undefined });
const groups = groupByWorktree([emptyWorkspace, missingWorkspace], [emptyWorkspace, missingWorkspace], 2);
expect(groups).toEqual([expect.objectContaining({
id: "unassigned", kind: "unassigned", activeTasks: [emptyWorkspace, missingWorkspace],
})]);
});
it("prefers a singular worktree for transient rows that contain both shapes", () => {
const transient = makeTask({
id: "FN-transient",
worktree: "/ws/.worktrees/single-worktree",
workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-transient", branch: "fusion/FN-transient" },
},
});
expect(groupByWorktree([transient], [transient], 2)).toEqual([expect.objectContaining({
id: "/ws/.worktrees/single-worktree", kind: "worktree", label: "single-worktree",
})]);
});
it("keeps basename-colliding workspace and singular worktree groups distinct", () => {
const workspaceA = makeTask({ id: "FN-workspace-a", workspaceWorktrees: {
"repo-a": { worktreePath: "/ws/repo-a/.worktrees/FN-9044", branch: "fusion/a" },
} });
const workspaceB = makeTask({ id: "FN-workspace-b", workspaceWorktrees: {
"repo-b": { worktreePath: "/ws/repo-b/.worktrees/FN-9044", branch: "fusion/b" },
} });
const singleA = makeTask({ id: "FN-single-a", worktree: "/ws/repo-a/.worktrees/FN-9044" });
const singleB = makeTask({ id: "FN-single-b", worktree: "/ws/repo-b/.worktrees/FN-9044" });
const groups = groupByWorktree([workspaceA, workspaceB, singleA, singleB], [workspaceA, workspaceB, singleA, singleB], 2);
expect(groups).toHaveLength(4);
expect(groups.map((group) => group.id)).toEqual([
"/ws/repo-a/.worktrees/FN-9044",
"/ws/repo-b/.worktrees/FN-9044",
"workspace:FN-workspace-a",
"workspace:FN-workspace-b",
]);
expect(new Set(groups.map((group) => group.id)).size).toBe(4);
});
it("excludes paused todo tasks from Up Next", () => { it("excludes paused todo tasks from Up Next", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" }); const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const paused = makeTask({ const paused = makeTask({

View File

@@ -1,9 +1,13 @@
import type { Task } from "@fusion/core"; import { isWorkspaceTask, type Task } from "@fusion/core";
import { getPathBasename } from "./pathDisplay"; import { getPathBasename } from "./pathDisplay";
import { isArchivedColumnRole, isCompleteColumnRole, isHoldColumnRole, isReviewColumnRole } from "./columnRoles"; import { isArchivedColumnRole, isCompleteColumnRole, isHoldColumnRole, isReviewColumnRole } from "./columnRoles";
export interface WorktreeGroupData { export interface WorktreeGroupData {
/** Stable identity; display labels collide for separate worktree paths. */
id: string;
kind: "worktree" | "workspace" | "unassigned" | "up-next";
label: string; label: string;
repoCount?: number;
activeTasks: Task[]; activeTasks: Task[];
queuedTasks: Task[]; queuedTasks: Task[];
} }
@@ -85,9 +89,15 @@ export function groupByWorktree(
*/ */
dependencyColumnFlags?: ReadonlyMap<string, Parameters<typeof isCompleteColumnRole>[0]>, dependencyColumnFlags?: ReadonlyMap<string, Parameters<typeof isCompleteColumnRole>[0]>,
): WorktreeGroupData[] { ): WorktreeGroupData[] {
// Separate assigned vs unassigned in-progress tasks /*
const assigned = inProgressTasks.filter((t) => t.worktree); FNXC:Workspace 2026-08-15-03:35:
const unassigned = inProgressTasks.filter((t) => !t.worktree); A workspace task legitimately has no singular `worktree` while owning one per-repository
worktree. Boolean(task.worktree) therefore is not its assignment test. Workspace worktrees
commonly share a basename, so groups use stable ids rather than labels as React keys.
*/
const assigned = inProgressTasks.filter((task) => Boolean(task.worktree));
const workspaceTasks = inProgressTasks.filter((task) => !task.worktree && isWorkspaceTask(task));
const unassigned = inProgressTasks.filter((task) => !task.worktree && !isWorkspaceTask(task));
// Group assigned tasks by worktree // Group assigned tasks by worktree
const worktreeMap = new Map<string, Task[]>(); const worktreeMap = new Map<string, Task[]>();
@@ -143,15 +153,32 @@ export function groupByWorktree(
for (const key of worktreeKeys) { for (const key of worktreeKeys) {
groups.push({ groups.push({
id: key,
kind: "worktree",
label: getWorktreeLabel(key), label: getWorktreeLabel(key),
activeTasks: worktreeMap.get(key)!, activeTasks: worktreeMap.get(key)!,
queuedTasks: [], queuedTasks: [],
}); });
} }
for (const task of workspaceTasks) {
const entries = task.workspaceWorktrees!;
const firstRepo = Object.keys(entries).sort()[0]!;
groups.push({
id: `workspace:${task.id}`,
kind: "workspace",
label: getWorktreeLabel(entries[firstRepo]!.worktreePath),
repoCount: Object.keys(entries).length,
activeTasks: [task],
queuedTasks: [],
});
}
// Add unassigned group if needed // Add unassigned group if needed
if (unassigned.length > 0) { if (unassigned.length > 0) {
groups.push({ groups.push({
id: "unassigned",
kind: "unassigned",
label: "Unassigned", label: "Unassigned",
activeTasks: unassigned, activeTasks: unassigned,
queuedTasks: [], queuedTasks: [],
@@ -162,6 +189,8 @@ export function groupByWorktree(
const queued = orderedEligible.slice(0, maxConcurrent); const queued = orderedEligible.slice(0, maxConcurrent);
if (queued.length > 0) { if (queued.length > 0) {
groups.push({ groups.push({
id: "up-next",
kind: "up-next",
label: "Up Next", label: "Up Next",
activeTasks: [], activeTasks: [],
queuedTasks: queued, queuedTasks: queued,

View File

@@ -9301,7 +9301,9 @@
}, },
"worktree": { "worktree": {
"unassigned": "Unassigned", "unassigned": "Unassigned",
"upNext": "Up Next" "upNext": "Up Next",
"workspaceRepos_one": "{{label}} · {{count}} repo",
"workspaceRepos_other": "{{label}} · {{count}} repos"
}, },
"worktrunk": { "worktrunk": {
"assetUrl": "Asset URL", "assetUrl": "Asset URL",

View File

@@ -9359,7 +9359,9 @@ export default interface Resources {
}, },
"worktree": { "worktree": {
"unassigned": "Unassigned", "unassigned": "Unassigned",
"upNext": "Up Next" "upNext": "Up Next",
"workspaceRepos_one": "{{label}} · {{count}} repo",
"workspaceRepos_other": "{{label}} · {{count}} repos"
}, },
"worktrunk": { "worktrunk": {
"assetUrl": "Asset URL", "assetUrl": "Asset URL",