mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 11:29:28 +02:00
Compare commits
1 Commits
license/mo
...
fix/sub-is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02cc19249 |
221
LICENSE
221
LICENSE
@@ -1,44 +1,199 @@
|
||||
# Open Source License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Multica may be utilized commercially, including as a backend service for
|
||||
other applications or as a task management platform for enterprises.
|
||||
Should the conditions below be met, a commercial license must be obtained
|
||||
from the producer:
|
||||
1. Definitions.
|
||||
|
||||
a. Hosted or embedded service: Unless explicitly authorized by Multica
|
||||
in writing, you may not use the Multica source code to provide a
|
||||
hosted service to third parties, or embed Multica as a component of
|
||||
a product or service that is sold, licensed, or otherwise
|
||||
commercially distributed to third parties.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
- This restriction applies to offering Multica (in whole or
|
||||
substantial part) as a SaaS platform, a managed service, or as
|
||||
an integrated component within another commercial offering.
|
||||
- Internal use within a single organization (including multiple
|
||||
workspaces) does not require a commercial license.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
b. LOGO and copyright information: In the process of using Multica's
|
||||
frontend, you may not remove or modify the LOGO or copyright
|
||||
information in the Multica console or applications. This restriction
|
||||
is inapplicable to uses of Multica that do not involve its frontend.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
- Frontend Definition: For the purposes of this license, the
|
||||
"frontend" of Multica includes all components located in the
|
||||
`apps/web/` directory when running Multica from the raw source
|
||||
code, or the "web" image when running Multica with Docker.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
2. As a contributor, you should agree that:
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
a. The producer can adjust the open-source agreement to be more strict
|
||||
or relaxed as deemed necessary.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
b. Your contributed code may be used for commercial purposes, including
|
||||
but not limited to its cloud business operations.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
Apart from the specific conditions mentioned above, all other rights and
|
||||
restrictions follow the Apache License 2.0. Detailed information about the
|
||||
Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
© 2025 Multica, Inc.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an
|
||||
"Implied Patent License" from your patent counsel.
|
||||
|
||||
Copyright 2025 Multica
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 flex-1 max-w-md" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@@ -7,7 +8,6 @@ import { QueryProvider } from "@core/provider";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WSProvider } from "@/features/realtime";
|
||||
import { ModalRegistry } from "@/features/modals";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
@@ -51,19 +51,22 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("multica-locale")?.value;
|
||||
const lang = locale === "zh" ? "zh" : "en";
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={lang}
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<AuthInitializer>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Reads the locale cookie on the client and updates <html lang>.
|
||||
* This avoids calling cookies() in the root Server Component layout,
|
||||
* which would mark the entire app as dynamic and disable the Router Cache.
|
||||
*/
|
||||
export function LocaleSync() {
|
||||
useEffect(() => {
|
||||
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
|
||||
const locale = match?.[1];
|
||||
if (locale === "zh") {
|
||||
document.documentElement.lang = "zh";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function useLoadMoreDoneIssues() {
|
||||
}
|
||||
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, doneTotal };
|
||||
return { loadMore, hasMore, isLoading };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -90,10 +90,6 @@ export function useCreateIssue() {
|
||||
}
|
||||
: old,
|
||||
);
|
||||
// Invalidate parent's children query so sub-issues list updates immediately
|
||||
if (newIssue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
@@ -116,17 +112,6 @@ export function useUpdateIssue() {
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
// parent's children cache in sync (used by the parent issue's
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
prevDetail?.parent_issue_id ??
|
||||
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
|
||||
null;
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
@@ -140,34 +125,16 @@ export function useUpdateIssue() {
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, ...data } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(
|
||||
issueKeys.children(wsId, parentId),
|
||||
(old) =>
|
||||
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
|
||||
);
|
||||
}
|
||||
return { prevList, prevDetail, prevChildren, parentId, id };
|
||||
return { prevList, prevDetail, id };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevDetail)
|
||||
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||
if (ctx?.parentId && ctx.prevChildren !== undefined) {
|
||||
qc.setQueryData(
|
||||
issueKeys.children(wsId, ctx.parentId),
|
||||
ctx.prevChildren,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, ctx.parentId),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@ export function onIssueCreated(
|
||||
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
}
|
||||
}
|
||||
|
||||
export function onIssueUpdated(
|
||||
@@ -27,17 +24,6 @@ export function onIssueUpdated(
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
) {
|
||||
// Look up the parent before mutating list state, so we can also keep the
|
||||
// parent's children cache in sync (powers the sub-issues list shown on
|
||||
// the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const parentId =
|
||||
issue.parent_issue_id ??
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
null;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const prev = old.issues.find((i) => i.id === issue.id);
|
||||
@@ -60,11 +46,6 @@ export function onIssueUpdated(
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
@@ -72,26 +53,18 @@ export function onIssueDeleted(
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = listData?.issues.find((i) => i.id === issueId);
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const del = old.issues.find((i) => i.id === issueId);
|
||||
const deleted = old.issues.find((i) => i.id === issueId);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== issueId),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
|
||||
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
if (deleted?.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,11 @@ export function BoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
totalCount,
|
||||
footer,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
totalCount?: number;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
@@ -56,7 +54,7 @@ export function BoardColumn({
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalCount ?? issueIds.length}
|
||||
{issueIds.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,30 +33,6 @@ import { StatusIcon } from "./status-icon";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
||||
/** Sentinel that triggers `onVisible` when scrolled into view. */
|
||||
function InfiniteScrollSentinel({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const onVisibleRef = useRef(onVisible);
|
||||
onVisibleRef.current = onVisible;
|
||||
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry?.isIntersecting) onVisibleRef.current(); },
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={sentinelRef} className="flex items-center justify-center py-2">
|
||||
{loading && <Loader2 className="size-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
|
||||
|
||||
const kanbanCollision: CollisionDetection = (args) => {
|
||||
@@ -135,7 +111,7 @@ export function BoardView({
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal } = useLoadMoreDoneIssues();
|
||||
const { loadMore, hasMore, isLoading: loadingMore } = useLoadMoreDoneIssues();
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
@@ -298,10 +274,19 @@ export function BoardView({
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMapRef.current}
|
||||
totalCount={status === "done" ? doneTotal : undefined}
|
||||
footer={
|
||||
status === "done" && hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full items-center justify-center gap-1.5 rounded-md py-2 text-xs text-muted-foreground hover:bg-accent/60 transition-colors disabled:opacity-50"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : null}
|
||||
{loadingMore ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -78,60 +78,6 @@ import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Tiny circular progress ring used in the "Sub-issue of …" line and the
|
||||
* Sub-issues section header. Renders an open ring when in-progress and
|
||||
* fills to a solid arc when complete.
|
||||
*/
|
||||
function ProgressRing({
|
||||
done,
|
||||
total,
|
||||
size = 12,
|
||||
}: {
|
||||
done: number;
|
||||
total: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const stroke = 1.5;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
||||
const offset = circumference * (1 - ratio);
|
||||
const isComplete = total > 0 && done >= total;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className={isComplete ? "text-info" : "text-primary"}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
if (!date) return "—";
|
||||
@@ -256,6 +202,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
|
||||
@@ -291,13 +238,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
...childIssuesOptions(wsId, id),
|
||||
enabled: !!issue,
|
||||
});
|
||||
// Parent's children — used to render the "x/y" progress next to the
|
||||
// "Sub-issue of …" breadcrumb under the title.
|
||||
const { data: parentChildIssues = [] } = useQuery({
|
||||
...childIssuesOptions(wsId, parentIssueId ?? ""),
|
||||
enabled: !!parentIssueId,
|
||||
});
|
||||
const [subIssuesCollapsed, setSubIssuesCollapsed] = useState(false);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
@@ -317,6 +257,23 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}
|
||||
}, [highlightCommentId, timeline.length]);
|
||||
|
||||
// Track scroll position for jump-to-bottom button
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// Issue field updates via TQ mutation (optimistic update + rollback in mutation hook)
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleUpdateField = useCallback(
|
||||
@@ -706,31 +663,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}}
|
||||
/>
|
||||
|
||||
{parentIssue && (
|
||||
<Link
|
||||
href={`/issues/${parentIssue.id}`}
|
||||
className="mt-2 inline-flex max-w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors group/parent"
|
||||
>
|
||||
<span className="font-medium shrink-0">Sub-issue of</span>
|
||||
<StatusIcon status={parentIssue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="tabular-nums shrink-0">{parentIssue.identifier}</span>
|
||||
<span className="truncate group-hover/parent:text-foreground">
|
||||
{parentIssue.title}
|
||||
</span>
|
||||
{parentChildIssues.length > 0 && (() => {
|
||||
const done = parentChildIssues.filter((c) => c.status === "done").length;
|
||||
return (
|
||||
<span className="ml-1 inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 shrink-0">
|
||||
<ProgressRing done={done} total={parentChildIssues.length} size={11} />
|
||||
<span className="tabular-nums text-[10.5px] font-medium">
|
||||
{done}/{parentChildIssues.length}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
@@ -761,122 +693,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sub-issues — Linear-style */}
|
||||
{childIssues.length === 0 && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>Add sub-issues</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{childIssues.length > 0 && (() => {
|
||||
const doneCount = childIssues.filter((c) => c.status === "done").length;
|
||||
return (
|
||||
<div className="mt-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubIssuesCollapsed((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-transform",
|
||||
subIssuesCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span>Sub-issues</span>
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-2 py-0.5">
|
||||
<ProgressRing done={doneCount} total={childIssues.length} size={11} />
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
|
||||
{doneCount}/{childIssues.length}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
})
|
||||
}
|
||||
aria-label="Add sub-issue"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Add sub-issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{!subIssuesCollapsed && (
|
||||
<div className="overflow-hidden rounded-lg border bg-card/30 divide-y divide-border/60">
|
||||
{childIssues.map((child) => {
|
||||
const isDone =
|
||||
child.status === "done" || child.status === "cancelled";
|
||||
return (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/issues/${child.id}`}
|
||||
className="flex items-center gap-2.5 px-3 py-2 hover:bg-accent/50 transition-colors group/row"
|
||||
>
|
||||
<StatusIcon
|
||||
status={child.status}
|
||||
className="h-[15px] w-[15px] shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums font-medium shrink-0">
|
||||
{child.identifier}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate flex-1",
|
||||
isDone
|
||||
? "text-muted-foreground"
|
||||
: "group-hover/row:text-foreground",
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
</span>
|
||||
{child.assignee_type && child.assignee_id ? (
|
||||
<ActorAvatar
|
||||
actorType={child.assignee_type}
|
||||
actorId={child.assignee_id}
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-5 w-5 rounded-full border border-dashed border-muted-foreground/30 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
{/* Activity / Comments */}
|
||||
@@ -1123,6 +939,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Jump to bottom button */}
|
||||
{showScrollBottom && (
|
||||
<div className="sticky bottom-4 flex justify-center pointer-events-none">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pointer-events-auto shadow-md"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -1231,6 +1061,43 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-issues */}
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
|
||||
Sub-issues
|
||||
{childIssues.length > 0 && (
|
||||
<span className="text-muted-foreground">{childIssues.length}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto p-0.5 rounded hover:bg-accent/60 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue", {
|
||||
parent_issue_id: issue.id,
|
||||
parent_issue_identifier: issue.identifier,
|
||||
})}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-2 space-y-0.5">
|
||||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/issues/${child.id}`}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 -mx-2 text-xs hover:bg-accent/50 transition-colors group"
|
||||
>
|
||||
<StatusIcon status={child.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground shrink-0">{child.identifier}</span>
|
||||
<span className="truncate group-hover:text-foreground">{child.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
{childIssues.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground px-2">No sub-issues</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -272,24 +272,6 @@ export const en: LandingDict = {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "Sub-Issues, TanStack Query & Usage Tracking",
|
||||
changes: [
|
||||
"Sub-issue support — create, view, and manage child issues within any issue",
|
||||
"Full migration to TanStack Query for server state (issues, inbox, workspace, runtimes)",
|
||||
"Per-task token usage tracking across all agent providers",
|
||||
"Multiple agents can now run concurrently on the same issue",
|
||||
"Board view: Done column shows total count with infinite scroll",
|
||||
"ReadonlyContent component for lightweight Markdown display in comments",
|
||||
"Optimistic UI updates for reactions and mutations with rollback",
|
||||
"WebSocket-driven cache invalidation replaces polling and refetch-on-focus",
|
||||
"Browser session persists during CLI login flow",
|
||||
"Daemon reuses existing worktrees by updating to latest remote",
|
||||
"Fixed slow tab switching caused by dynamic root layout",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
|
||||
@@ -272,24 +272,6 @@ export const zh: LandingDict = {
|
||||
title: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "子 Issue、TanStack Query 与用量追踪",
|
||||
changes: [
|
||||
"子 Issue 支持——在任意 Issue 内创建、查看和管理子任务",
|
||||
"全面迁移至 TanStack Query 管理服务端状态(Issue、收件箱、工作区、运行时)",
|
||||
"按任务维度追踪所有 Agent 提供商的 token 用量",
|
||||
"同一 Issue 支持多个 Agent 并发执行",
|
||||
"看板视图:Done 列显示总数并支持无限滚动",
|
||||
"新增 ReadonlyContent 组件,轻量渲染评论中的 Markdown",
|
||||
"表情反应和变更操作支持乐观更新与回滚",
|
||||
"WebSocket 驱动缓存失效,替代轮询和焦点刷新",
|
||||
"CLI 登录流程中浏览器会话保持不丢失",
|
||||
"守护进程复用已有 worktree 时自动拉取最新远程代码",
|
||||
"修复动态根布局导致的标签页切换卡顿问题",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
|
||||
Reference in New Issue
Block a user