feat: implement draft team handling and metadata storage
- Added functionality to read draft team summaries from team.meta.json when config.json is missing. - Introduced methods to save team-level metadata to team.meta.json during team creation. - Updated UI components to handle draft teams, including launch and delete options. - Enhanced error handling for draft teams in various components.
This commit is contained in:
parent
087a8f4989
commit
e837eb7db8
8 changed files with 338 additions and 31 deletions
|
|
@ -238,7 +238,6 @@ pnpm dist # macOS + Windows + Linux
|
||||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||||
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
||||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||||
- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents
|
|
||||||
- [ ] Planning mode to organize agent plans before execution
|
- [ ] Planning mode to organize agent plans before execution
|
||||||
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
||||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||||
|
|
|
||||||
|
|
@ -1003,7 +1003,11 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relative === 'config.json' || relative === 'kanban-state.json') {
|
if (
|
||||||
|
relative === 'config.json' ||
|
||||||
|
relative === 'kanban-state.json' ||
|
||||||
|
relative === 'team.meta.json'
|
||||||
|
) {
|
||||||
const event: TeamChangeEvent = {
|
const event: TeamChangeEvent = {
|
||||||
type: 'config',
|
type: 'config',
|
||||||
teamName,
|
teamName,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
|
|
||||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -152,8 +153,8 @@ export class TeamConfigReader {
|
||||||
|
|
||||||
// Skip non-regular files (pipes, sockets, etc.) — readFile could hang on them
|
// Skip non-regular files (pipes, sockets, etc.) — readFile could hang on them
|
||||||
if (!stat?.isFile()) {
|
if (!stat?.isFile()) {
|
||||||
logger.debug(`Skipping team dir with missing/non-file config: ${teamName}`);
|
// Fallback: check for draft team (team.meta.json without config.json)
|
||||||
return null;
|
return this.readDraftTeamSummary(teamsDir, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety: refuse to touch extremely large configs. Even "head" parsing can be misleading,
|
// Safety: refuse to touch extremely large configs. Even "head" parsing can be misleading,
|
||||||
|
|
@ -283,6 +284,57 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for a draft team (team.meta.json exists without config.json).
|
||||||
|
* This happens when provisioning failed before CLI's TeamCreate could run.
|
||||||
|
*/
|
||||||
|
private async readDraftTeamSummary(
|
||||||
|
teamsDir: string,
|
||||||
|
teamName: string
|
||||||
|
): Promise<TeamSummary | null> {
|
||||||
|
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
||||||
|
try {
|
||||||
|
const metaStat = await fs.promises.stat(metaPath);
|
||||||
|
if (!metaStat.isFile() || metaStat.size > 256 * 1024) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const metaRaw = await readFileUtf8WithTimeout(metaPath, PER_TEAM_READ_TIMEOUT_MS);
|
||||||
|
const meta = JSON.parse(metaRaw) as Record<string, unknown>;
|
||||||
|
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
typeof meta.displayName === 'string' && meta.displayName.trim()
|
||||||
|
? meta.displayName.trim()
|
||||||
|
: teamName;
|
||||||
|
|
||||||
|
let memberCount = 0;
|
||||||
|
try {
|
||||||
|
const metaStore = new TeamMembersMetaStore();
|
||||||
|
const members = await metaStore.getMembers(teamName);
|
||||||
|
memberCount = members.length;
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
displayName,
|
||||||
|
description: typeof meta.description === 'string' ? meta.description : '',
|
||||||
|
memberCount,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActivity:
|
||||||
|
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||||
|
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||||
|
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||||
|
pendingCreate: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { TeamInboxWriter } from './TeamInboxWriter';
|
||||||
import { TeamKanbanManager } from './TeamKanbanManager';
|
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
|
@ -113,7 +114,8 @@ export class TeamDataService {
|
||||||
teamName,
|
teamName,
|
||||||
claudeDir: getClaudeBasePath(),
|
claudeDir: getClaudeBasePath(),
|
||||||
}),
|
}),
|
||||||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal()
|
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||||
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private getController(teamName: string): AgentTeamsController {
|
private getController(teamName: string): AgentTeamsController {
|
||||||
|
|
@ -1611,6 +1613,7 @@ export class TeamDataService {
|
||||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||||
const configPath = path.join(teamDir, 'config.json');
|
const configPath = path.join(teamDir, 'config.json');
|
||||||
|
|
||||||
|
// Check if team already exists (config.json = fully created by CLI)
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(configPath, fs.constants.F_OK);
|
await fs.promises.access(configPath, fs.constants.F_OK);
|
||||||
throw new Error(`Team already exists: ${request.teamName}`);
|
throw new Error(`Team already exists: ${request.teamName}`);
|
||||||
|
|
@ -1625,17 +1628,18 @@ export class TeamDataService {
|
||||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||||
|
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const config: Record<string, unknown> = {
|
|
||||||
name: request.displayName?.trim() || request.teamName,
|
|
||||||
description: request.description?.trim() || undefined,
|
|
||||||
color: request.color?.trim() || undefined,
|
|
||||||
};
|
|
||||||
if (request.cwd?.trim()) {
|
|
||||||
config.projectPath = request.cwd.trim();
|
|
||||||
config.projectPathHistory = [request.cwd.trim()];
|
|
||||||
}
|
|
||||||
|
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
// Save team-level metadata to team.meta.json (NOT config.json).
|
||||||
|
// config.json is CLI territory — created by TeamCreate during provisioning.
|
||||||
|
// team.meta.json preserves user's configuration for the Launch flow.
|
||||||
|
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||||
|
displayName: request.displayName,
|
||||||
|
description: request.description,
|
||||||
|
color: request.color,
|
||||||
|
cwd: request.cwd?.trim() || '',
|
||||||
|
createdAt: joinedAt,
|
||||||
|
});
|
||||||
|
|
||||||
await this.membersMetaStore.writeMembers(
|
await this.membersMetaStore.writeMembers(
|
||||||
request.teamName,
|
request.teamName,
|
||||||
request.members.map((member) => ({
|
request.members.map((member) => ({
|
||||||
|
|
|
||||||
124
src/main/services/team/TeamMetaStore.ts
Normal file
124
src/main/services/team/TeamMetaStore.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted team-level metadata saved by the UI before CLI provisioning.
|
||||||
|
* CLI does not know about this file — it only reads/writes config.json.
|
||||||
|
* If provisioning fails before TeamCreate, this file preserves user's
|
||||||
|
* configuration for retry.
|
||||||
|
*/
|
||||||
|
export interface TeamMetaFile {
|
||||||
|
version: 1;
|
||||||
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
cwd: string;
|
||||||
|
prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
effort?: string;
|
||||||
|
skipPermissions?: boolean;
|
||||||
|
worktree?: string;
|
||||||
|
extraCliArgs?: string;
|
||||||
|
limitContext?: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_META_FILE_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
export class TeamMetaStore {
|
||||||
|
private getMetaPath(teamName: string): string {
|
||||||
|
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeta(teamName: string): Promise<TeamMetaFile | null> {
|
||||||
|
const metaPath = this.getMetaPath(teamName);
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(metaPath);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_META_FILE_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFileUtf8WithTimeout(metaPath, 5_000);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
|
||||||
|
error instanceof FileReadTimeoutError
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = parsed as Partial<TeamMetaFile>;
|
||||||
|
if (file.version !== 1 || typeof file.cwd !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
displayName:
|
||||||
|
typeof file.displayName === 'string' ? file.displayName.trim() || undefined : undefined,
|
||||||
|
description:
|
||||||
|
typeof file.description === 'string' ? file.description.trim() || undefined : undefined,
|
||||||
|
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
||||||
|
cwd: file.cwd.trim(),
|
||||||
|
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
|
||||||
|
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
|
||||||
|
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
||||||
|
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
||||||
|
worktree: typeof file.worktree === 'string' ? file.worktree.trim() || undefined : undefined,
|
||||||
|
extraCliArgs:
|
||||||
|
typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined,
|
||||||
|
limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined,
|
||||||
|
createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeMeta(teamName: string, data: Omit<TeamMetaFile, 'version'>): Promise<void> {
|
||||||
|
const payload: TeamMetaFile = {
|
||||||
|
version: 1,
|
||||||
|
displayName: data.displayName?.trim() || undefined,
|
||||||
|
description: data.description?.trim() || undefined,
|
||||||
|
color: data.color?.trim() || undefined,
|
||||||
|
cwd: data.cwd.trim(),
|
||||||
|
prompt: data.prompt?.trim() || undefined,
|
||||||
|
model: data.model?.trim() || undefined,
|
||||||
|
effort: data.effort?.trim() || undefined,
|
||||||
|
skipPermissions: data.skipPermissions,
|
||||||
|
worktree: data.worktree?.trim() || undefined,
|
||||||
|
extraCliArgs: data.extraCliArgs?.trim() || undefined,
|
||||||
|
limitContext: data.limitContext,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
};
|
||||||
|
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMeta(teamName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(this.getMetaPath(teamName));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -316,6 +316,57 @@ function dropCliProvisionerMembers(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a draft team summary from team.meta.json when config.json is missing.
|
||||||
|
* Returns null if team.meta.json doesn't exist or is invalid.
|
||||||
|
*/
|
||||||
|
async function readDraftTeamMeta(
|
||||||
|
teamsDir: string,
|
||||||
|
teamName: string
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(metaPath);
|
||||||
|
if (!stat.isFile() || stat.size > 256 * 1024) return null;
|
||||||
|
const raw = await fs.promises.readFile(metaPath, 'utf8');
|
||||||
|
const meta = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null;
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
typeof meta.displayName === 'string' && meta.displayName.trim()
|
||||||
|
? meta.displayName.trim()
|
||||||
|
: teamName;
|
||||||
|
|
||||||
|
// Read members.meta.json for member count
|
||||||
|
let memberCount = 0;
|
||||||
|
try {
|
||||||
|
const membersPath = path.join(teamsDir, teamName, 'members.meta.json');
|
||||||
|
const membersRaw = await fs.promises.readFile(membersPath, 'utf8');
|
||||||
|
const membersData = JSON.parse(membersRaw) as { members?: unknown[] };
|
||||||
|
if (Array.isArray(membersData?.members)) {
|
||||||
|
memberCount = membersData.members.length;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
displayName,
|
||||||
|
description: typeof meta.description === 'string' ? meta.description : '',
|
||||||
|
memberCount,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActivity:
|
||||||
|
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||||
|
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||||
|
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||||
|
pendingCreate: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function listTeams(
|
async function listTeams(
|
||||||
payload: ListTeamsPayload
|
payload: ListTeamsPayload
|
||||||
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
|
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
|
||||||
|
|
@ -358,9 +409,16 @@ async function listTeams(
|
||||||
try {
|
try {
|
||||||
stat = await fs.promises.stat(configPath);
|
stat = await fs.promises.stat(configPath);
|
||||||
} catch {
|
} catch {
|
||||||
|
// Fallback: check for draft team (team.meta.json without config.json)
|
||||||
|
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
||||||
|
if (draft) return draft;
|
||||||
return skip('config_stat_failed');
|
return skip('config_stat_failed');
|
||||||
}
|
}
|
||||||
if (!stat.isFile()) return skip('config_not_file');
|
if (!stat.isFile()) {
|
||||||
|
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
||||||
|
if (draft) return draft;
|
||||||
|
return skip('config_not_file');
|
||||||
|
}
|
||||||
if (stat.size > payload.maxConfigBytes) return skip('config_too_large');
|
if (stat.size > payload.maxConfigBytes) return skip('config_too_large');
|
||||||
|
|
||||||
let config: ParsedConfig | null = null;
|
let config: ParsedConfig | null = null;
|
||||||
|
|
|
||||||
|
|
@ -963,6 +963,52 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error === 'TEAM_DRAFT') {
|
||||||
|
const teamSummary = teams.find((t) => t.teamName === teamName);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-full items-center justify-center p-6">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<p className="text-sm font-medium text-text">Team not launched yet</p>
|
||||||
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
|
{teamSummary?.displayName || teamName} configuration has been saved. Launch to start
|
||||||
|
provisioning with CLI.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-center gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||||||
|
onClick={() => setLaunchDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Launch
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text"
|
||||||
|
onClick={() => {
|
||||||
|
void api.teams.deleteDraft(teamName).catch(() => {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LaunchTeamDialog
|
||||||
|
mode="launch"
|
||||||
|
open={launchDialogOpen}
|
||||||
|
teamName={teamName}
|
||||||
|
members={[]}
|
||||||
|
defaultProjectPath={teamSummary?.projectPath}
|
||||||
|
provisioningError={provisioningError}
|
||||||
|
clearProvisioningError={clearProvisioningError}
|
||||||
|
onClose={() => setLaunchDialogOpen(false)}
|
||||||
|
onLaunch={async (request) => {
|
||||||
|
await launchTeam(request);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center p-6">
|
<div className="flex size-full items-center justify-center p-6">
|
||||||
|
|
|
||||||
|
|
@ -407,9 +407,22 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
||||||
|
|
||||||
const handleDeleteTeam = useCallback(
|
const handleDeleteTeam = useCallback(
|
||||||
(teamName: string, e: React.MouseEvent) => {
|
(teamName: string, isDraft: boolean, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
if (isDraft) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Delete draft',
|
||||||
|
message: `Delete draft team "${teamName}"? This cannot be undone.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
variant: 'danger',
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
void api.teams.deleteDraft(teamName).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Move to trash',
|
title: 'Move to trash',
|
||||||
message: `Move team "${teamName}" to trash? You can restore it later.`,
|
message: `Move team "${teamName}" to trash? You can restore it later.`,
|
||||||
|
|
@ -529,7 +542,10 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||||
setLaunchDialogOpen(true);
|
setLaunchDialogOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load team data for launch dialog:', err);
|
// Draft teams (no config.json) throw TEAM_DRAFT — expected, use fallback
|
||||||
|
if (!(err instanceof Error && err.message.includes('TEAM_DRAFT'))) {
|
||||||
|
console.error('Failed to load team data for launch dialog:', err);
|
||||||
|
}
|
||||||
// Fallback: open dialog with minimal data
|
// Fallback: open dialog with minimal data
|
||||||
setLaunchDialogTeamName(teamName);
|
setLaunchDialogTeamName(teamName);
|
||||||
setLaunchDialogMembers([]);
|
setLaunchDialogMembers([]);
|
||||||
|
|
@ -840,24 +856,28 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip>
|
{!team.pendingCreate && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
|
type="button"
|
||||||
onClick={(e) => handleCopyTeam(team.teamName, e)}
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
|
||||||
>
|
onClick={(e) => handleCopyTeam(team.teamName, e)}
|
||||||
<Copy size={14} />
|
>
|
||||||
</button>
|
<Copy size={14} />
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent side="bottom">Copy team</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="bottom">Copy team</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
|
||||||
onClick={(e) => handleDeleteTeam(team.teamName, e)}
|
onClick={(e) =>
|
||||||
|
handleDeleteTeam(team.teamName, !!team.pendingCreate, e)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue