Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
30aa949ac6 fix(settings): invalidate issue cache when workspace prefix changes (MUL-2369)
Issue identifiers (`MUL-123`) are recomputed from `workspace.issue_prefix`
at read time, so cached issues kept showing the old `OLD-N` keys after a
prefix change. Without invalidation the confirm dialog's "all issues will
be renumbered" promise was broken until a hard refresh — and other tabs
receiving the `workspace:updated` WS event saw the same drift.

- WorkspaceTab: after a prefix-changing save, invalidate `issueKeys.all`
  in addition to the workspace list. Non-prefix saves stay cheap.
- Realtime: split `workspace:updated` out of the generic `workspace`
  refresh into a specific handler that compares cached vs incoming
  `issue_prefix` and invalidates issues only when it actually changed.
- Docs: align the "uppercase" language with the actual UI/backend rule
  (uppercase letters and digits, up to 10 chars).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:47:26 +08:00
Jiang Bohan
6d64141e2c feat(settings): allow editing workspace issue prefix (MUL-2369)
Workspace admins can now change the issue prefix from Settings → General.
The change is gated by a confirmation dialog that warns about external
references (PR titles, branch names, links) breaking, because issue
identifiers are rendered as `prefix-N` on the fly — changing the prefix
effectively renames every existing issue.

Refs https://github.com/multica-ai/multica/issues/2797

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:29:17 +08:00
11 changed files with 439 additions and 9 deletions

View File

@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
### Comments in code

View File

@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效
### 代码注释

View File

@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
<Callout type="warning">
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."

View File

@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
- **Slug短链标识符** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符
<Callout type="warning">
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。

View File

@@ -1102,7 +1102,7 @@ export class ApiClient {
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),

View File

@@ -1,8 +1,18 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
import { applyChatDoneToCache } from "./use-realtime-sync";
import { issueKeys } from "../issues/queries";
import { workspaceKeys } from "../workspace/queries";
import type {
ChatDonePayload,
ChatMessage,
ChatPendingTask,
Workspace,
} from "../types";
import {
applyChatDoneToCache,
applyWorkspaceUpdatedToCache,
} from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
@@ -115,3 +125,78 @@ describe("applyChatDoneToCache", () => {
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});
describe("applyWorkspaceUpdatedToCache", () => {
const wsId = "ws-1";
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: wsId,
name: "Test",
slug: "test",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "TES",
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
it("invalidates issue cache when issue_prefix changes", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("does not invalidate issue cache when only non-prefix fields change", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES", name: "Old name" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
});
expect(invalidate).not.toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
// Conservative: a workspace appearing for the first time may correspond
// to issue queries that were primed without ever seeing the (possibly
// changing) prefix. Erring on the side of refresh keeps identifiers
// accurate at minimal cost.
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
});
});

View File

@@ -31,12 +31,14 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueD
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import type { Workspace } from "../types/workspace";
import { chatKeys } from "../chat/queries";
import { useChatStore } from "../chat";
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
WorkspaceUpdatedPayload,
MemberRemovedPayload,
IssueUpdatedPayload,
IssueCreatedPayload,
@@ -107,6 +109,36 @@ export function applyChatDoneToCache(
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
/**
* Apply a workspace:updated event to the cache. Always refreshes the
* workspace list. If the incoming `issue_prefix` differs from what's
* currently cached, also invalidates issueKeys.all for that workspace,
* since every issue's rendered identifier (`MUL-123`) is recomputed from
* the workspace prefix at read time. Without this, the UI keeps showing
* the old `OLD-N` keys until the next hard refresh.
*
* If the workspace isn't in the cached list (first observation), we
* conservatively invalidate — the prefix is effectively "new" relative to
* what's cached, so any issues already loaded under the old prefix would
* be stale anyway.
*/
export function applyWorkspaceUpdatedToCache(
qc: QueryClient,
payload: WorkspaceUpdatedPayload,
): void {
const next = payload.workspace;
if (next?.id) {
const cached =
qc
.getQueryData<Workspace[]>(workspaceKeys.list())
?.find((w) => w.id === next.id) ?? null;
if (!cached || cached.issue_prefix !== next.issue_prefix) {
qc.invalidateQueries({ queryKey: issueKeys.all(next.id) });
}
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
/**
* Invalidates all workspace-scoped queries. Used after reconnect and when a
* new WSClient instance is detected (workspace switch) to recover events
@@ -191,6 +223,11 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
},
// workspace:updated is handled by the specific handler below
// (compares prefixes to decide whether to also invalidate issues).
// This generic fallback still fires for workspace:deleted (paired
// with the specific navigation handler) and any future workspace:*
// events without dedicated handlers.
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -303,6 +340,7 @@ export function useRealtimeSync(
// Event types handled by specific handlers below -- skip generic refresh
const specificEvents = new Set([
"workspace:updated",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"comment:resolved", "comment:unresolved",
@@ -540,6 +578,10 @@ export function useRealtimeSync(
}
};
const unsubWsUpdated = ws.on("workspace:updated", (p) => {
applyWorkspaceUpdatedToCache(qc, p as WorkspaceUpdatedPayload);
});
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
// Event payload has UUID; look up slug from cached workspace list
@@ -849,6 +891,7 @@ export function useRealtimeSync(
unsubIssueReactionRemoved();
unsubSubscriberAdded();
unsubSubscriberRemoved();
unsubWsUpdated();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();

View File

@@ -116,6 +116,10 @@
"context_label": "Context",
"context_placeholder": "Background information and context for AI agents working in this workspace",
"slug_label": "Slug",
"issue_prefix_label": "Issue prefix",
"issue_prefix_hint": "Used in issue numbers like {{example}}.",
"prefix_confirm_title": "Change issue prefix?",
"prefix_confirm_description": "All issues will be renumbered from {{oldPrefix}}-N to {{newPrefix}}-N. External references — pull request titles, branch names, links in docs and chat — that use {{oldPrefix}}-N will stop resolving.",
"save": "Save",
"saving": "Saving...",
"manage_hint": "Only admins and owners can update workspace settings.",

View File

@@ -116,6 +116,10 @@
"context_label": "上下文",
"context_placeholder": "供工作区中的智能体参考的背景信息和上下文",
"slug_label": "Slug",
"issue_prefix_label": "Issue 前缀",
"issue_prefix_hint": "用于 issue 编号,例如 {{example}}。",
"prefix_confirm_title": "确认修改 issue 前缀?",
"prefix_confirm_description": "所有 issue 将从 {{oldPrefix}}-N 重命名为 {{newPrefix}}-N。任何使用旧编号的外部引用——PR 标题、分支名、文档与聊天中的链接——都会失效。",
"save": "保存",
"saving": "保存中...",
"manage_hint": "只有管理员和所有者可以修改工作区设置。",

View File

@@ -0,0 +1,237 @@
import type { ReactNode } from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enSettings from "../../locales/en/settings.json";
const mockUpdateWorkspace = vi.hoisted(() => vi.fn());
const mockInvalidateQueries = vi.hoisted(() => vi.fn());
const workspaceRef = vi.hoisted(() => ({
current: {
id: "workspace-1",
name: "Test Workspace",
slug: "test-workspace",
description: "",
context: "",
issue_prefix: "TES",
repos: [] as { url: string }[],
},
}));
const membersRef = vi.hoisted(() => ({
current: [{ user_id: "user-1", role: "owner" as "owner" | "admin" | "member" }],
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: membersRef.current, isFetched: true }),
useQueryClient: () => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(() => []),
invalidateQueries: mockInvalidateQueries,
}),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "workspace-1",
}));
vi.mock("@multica/core/paths", () => ({
useCurrentWorkspace: () => workspaceRef.current,
useHasOnboarded: () => true,
resolvePostAuthDestination: () => "/",
}));
vi.mock("@multica/core/platform", () => ({
setCurrentWorkspace: vi.fn(),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
workspaceListOptions: () => ({ queryKey: ["workspaces"], queryFn: vi.fn() }),
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/issues/queries", () => ({
issueKeys: { all: (wsId: string) => ["issues", wsId] },
}));
vi.mock("@multica/core/workspace/mutations", () => ({
useLeaveWorkspace: () => ({ mutateAsync: vi.fn() }),
useDeleteWorkspace: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock("@multica/core/api", () => ({
api: { updateWorkspace: mockUpdateWorkspace },
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = Object.assign(
(sel?: (s: { user: { id: string } }) => unknown) =>
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
{ getState: () => ({ user: { id: "user-1" } }) },
);
return { useAuthStore };
});
vi.mock("../../navigation", () => ({
useNavigation: () => ({ push: vi.fn() }),
}));
vi.mock("./delete-workspace-dialog", () => ({
DeleteWorkspaceDialog: () => null,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { WorkspaceTab } from "./workspace-tab";
const TEST_RESOURCES = {
en: { common: enCommon, settings: enSettings },
};
function I18nWrapper({ children }: { children: ReactNode }) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>
);
}
describe("WorkspaceTab — issue prefix editing", () => {
beforeEach(() => {
vi.clearAllMocks();
workspaceRef.current = {
id: "workspace-1",
name: "Test Workspace",
slug: "test-workspace",
description: "",
context: "",
issue_prefix: "TES",
repos: [],
};
membersRef.current = [{ user_id: "user-1", role: "owner" }];
mockUpdateWorkspace.mockImplementation(
async (
_id: string,
payload: { issue_prefix?: string; name?: string },
) => ({
...workspaceRef.current,
...payload,
issue_prefix: payload.issue_prefix ?? workspaceRef.current.issue_prefix,
}),
);
});
it("renders the current prefix in the input", () => {
render(<WorkspaceTab />, { wrapper: I18nWrapper });
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
expect(input.value).toBe("TES");
});
it("uppercases and strips non-alphanumeric input as the user types", async () => {
const user = userEvent.setup();
render(<WorkspaceTab />, { wrapper: I18nWrapper });
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
await user.clear(input);
await user.type(input, "ab-12!cd");
expect(input.value).toBe("AB12CD");
});
it("saves directly without confirm when the prefix is unchanged", async () => {
const user = userEvent.setup();
render(<WorkspaceTab />, { wrapper: I18nWrapper });
await user.click(screen.getByRole("button", { name: /^Save$/ }));
await waitFor(() => {
expect(mockUpdateWorkspace).toHaveBeenCalledTimes(1);
});
// No issue_prefix in the payload when unchanged — avoids no-op churn
// and keeps the request shape identical to pre-feature behavior.
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
"workspace-1",
expect.not.objectContaining({ issue_prefix: expect.anything() }),
);
expect(screen.queryByText(/Change issue prefix/i)).toBeNull();
// Non-prefix saves must NOT invalidate the issue cache — would
// trigger an unnecessary workspace-wide refetch on every name edit.
expect(mockInvalidateQueries).not.toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ["issues", "workspace-1"] }),
);
});
it("shows a confirm dialog before saving when the prefix changes, and only saves on confirm", async () => {
const user = userEvent.setup();
render(<WorkspaceTab />, { wrapper: I18nWrapper });
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
await user.clear(input);
await user.type(input, "NEW");
await user.click(screen.getByRole("button", { name: /^Save$/ }));
// Save is gated behind the dialog — no API call yet.
expect(mockUpdateWorkspace).not.toHaveBeenCalled();
// Dialog body mentions both the old and new prefix in the warning.
await screen.findByText(/Change issue prefix/i);
expect(screen.getByText(/TES-N/)).toBeTruthy();
expect(screen.getByText(/NEW-N/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(mockUpdateWorkspace).toHaveBeenCalledTimes(1);
});
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
"workspace-1",
expect.objectContaining({ issue_prefix: "NEW" }),
);
// Issue identifiers (`MUL-123`) are recomputed from the workspace
// prefix at read time, so cached issues display the stale OLD-N key
// until invalidated. Without this the confirm dialog's promise that
// "all issues will be renumbered to NEW-N" is a lie.
expect(mockInvalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ["issues", "workspace-1"] }),
);
});
it("cancelling the confirm dialog does not save", async () => {
const user = userEvent.setup();
render(<WorkspaceTab />, { wrapper: I18nWrapper });
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
await user.clear(input);
await user.type(input, "NEW");
await user.click(screen.getByRole("button", { name: /^Save$/ }));
await screen.findByText(/Change issue prefix/i);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(mockUpdateWorkspace).not.toHaveBeenCalled();
// The user's edited value is preserved so they can resume.
expect(input.value).toBe("NEW");
});
it("disables Save when the prefix is empty", async () => {
const user = userEvent.setup();
render(<WorkspaceTab />, { wrapper: I18nWrapper });
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
await user.clear(input);
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
});
it("disables the prefix input for non-admins", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
render(<WorkspaceTab />, { wrapper: I18nWrapper });
expect(screen.getByPlaceholderText("TES")).toBeDisabled();
});
});

View File

@@ -27,6 +27,7 @@ import {
workspaceKeys,
workspaceListOptions,
} from "@multica/core/workspace/queries";
import { issueKeys } from "@multica/core/issues/queries";
import { api } from "@multica/core/api";
import {
resolvePostAuthDestination,
@@ -98,6 +99,7 @@ export function WorkspaceTab() {
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
const [context, setContext] = useState(workspace?.context ?? "");
const [issuePrefix, setIssuePrefix] = useState(workspace?.issue_prefix ?? "");
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
@@ -123,9 +125,21 @@ export function WorkspaceTab() {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
setIssuePrefix(workspace?.issue_prefix ?? "");
}, [workspace]);
const handleSave = async () => {
// Letters + digits only, uppercase, capped at 10 chars. The backend
// uppercases and trims on its side too — this is purely a UX guardrail
// so the value the user sees in the input matches what gets persisted.
const normalizePrefix = (raw: string) =>
raw.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 10);
const normalizedPrefix = normalizePrefix(issuePrefix);
const prefixChanged =
!!workspace && normalizedPrefix !== workspace.issue_prefix;
const prefixInvalid = normalizedPrefix.length === 0;
const performSave = async (includePrefix: boolean) => {
if (!workspace) return;
setSaving(true);
try {
@@ -133,10 +147,19 @@ export function WorkspaceTab() {
name,
description,
context,
...(includePrefix ? { issue_prefix: normalizedPrefix } : {}),
});
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
);
// Issue identifiers (`MUL-123`) are computed from `issue_prefix` at
// read time, not stored on each issue row. When the prefix changes,
// every cached issue's rendered identifier is stale until refetched.
// Limit invalidation to the prefix-changed branch so unrelated saves
// (name / description / context) stay cheap.
if (includePrefix) {
qc.invalidateQueries({ queryKey: issueKeys.all(updated.id) });
}
toast.success(t(($) => $.workspace.toast_saved));
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.workspace.toast_save_failed));
@@ -145,6 +168,23 @@ export function WorkspaceTab() {
}
};
const handleSave = () => {
if (!workspace || prefixInvalid) return;
if (prefixChanged) {
setConfirmAction({
title: t(($) => $.workspace.prefix_confirm_title),
description: t(($) => $.workspace.prefix_confirm_description, {
oldPrefix: workspace.issue_prefix,
newPrefix: normalizedPrefix,
}),
variant: "destructive",
onConfirm: () => performSave(true),
});
return;
}
void performSave(false);
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
@@ -231,11 +271,28 @@ export function WorkspaceTab() {
{workspace.slug}
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t(($) => $.workspace.issue_prefix_label)}</Label>
<Input
type="text"
value={issuePrefix}
onChange={(e) => setIssuePrefix(normalizePrefix(e.target.value))}
disabled={!canManageWorkspace}
maxLength={10}
className="mt-1 font-mono uppercase"
placeholder={workspace.issue_prefix}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.workspace.issue_prefix_hint, {
example: `${normalizedPrefix || workspace.issue_prefix}-123`,
})}
</p>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
disabled={saving || !name.trim() || prefixInvalid || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? t(($) => $.workspace.saving) : t(($) => $.workspace.save)}