merge: resolve conflicts with main (ExtensionStoreView, InstallButton)
Merged new fields from main (fetchCliStatus, cliStatusLoading, openDashboard, authMissing, disableReason) into our useShallow selectors.
This commit is contained in:
commit
f07eb914ae
8 changed files with 243 additions and 104 deletions
|
|
@ -27,8 +27,6 @@ import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||||
|
|
@ -691,10 +689,6 @@ export class TeamDataService {
|
||||||
);
|
);
|
||||||
mark('resolveMembers');
|
mark('resolveMembers');
|
||||||
|
|
||||||
// Enrich members with git branch when it differs from lead's branch
|
|
||||||
await this.enrichMemberBranches(members, config);
|
|
||||||
mark('enrichBranches');
|
|
||||||
|
|
||||||
mark('syncComments');
|
mark('syncComments');
|
||||||
|
|
||||||
let processes: TeamProcess[] = [];
|
let processes: TeamProcess[] = [];
|
||||||
|
|
@ -714,9 +708,9 @@ export class TeamDataService {
|
||||||
'sentMessages'
|
'sentMessages'
|
||||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||||
'kanbanGc'
|
'kanbanGc'
|
||||||
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince(
|
)} resolveMembers=${msSince('resolveMembers')} syncComments=${msSince('syncComments')} processes=${msSince(
|
||||||
'enrichBranches'
|
'processes'
|
||||||
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,68 +805,6 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
|
||||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
|
||||||
*/
|
|
||||||
private async enrichMemberBranches(
|
|
||||||
members: ResolvedTeamMember[],
|
|
||||||
config: TeamConfig
|
|
||||||
): Promise<void> {
|
|
||||||
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
|
||||||
const leadEntry = config.members?.find((m) => isLeadMember(m));
|
|
||||||
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
|
||||||
if (!leadCwd) return;
|
|
||||||
|
|
||||||
const withTimeout = async <T>(p: Promise<T>, ms: number): Promise<T> => {
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
p,
|
|
||||||
new Promise<T>((_resolve, reject) => {
|
|
||||||
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let leadBranch: string | null = null;
|
|
||||||
try {
|
|
||||||
// Git can hang on some Windows setups (network drives, locked repos, credential prompts).
|
|
||||||
// Branch is best-effort; never block team:getData on it.
|
|
||||||
leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
|
|
||||||
} catch {
|
|
||||||
// Lead cwd may not be a git repo — skip enrichment entirely
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
|
|
||||||
if (candidates.length === 0) return;
|
|
||||||
|
|
||||||
const concurrency = process.platform === 'win32' ? 4 : 8;
|
|
||||||
for (let i = 0; i < candidates.length; i += concurrency) {
|
|
||||||
const batch = candidates.slice(i, i + concurrency);
|
|
||||||
await Promise.all(
|
|
||||||
batch.map(async (member) => {
|
|
||||||
if (!member.cwd) return;
|
|
||||||
try {
|
|
||||||
const branch = await withTimeout(
|
|
||||||
gitIdentityResolver.getBranch(path.normalize(member.cwd)),
|
|
||||||
2000
|
|
||||||
);
|
|
||||||
if (branch && branch !== leadBranch) {
|
|
||||||
member.gitBranch = branch;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Member cwd may not be a git repo — skip silently
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures a member exists in members.meta.json.
|
* Ensures a member exists in members.meta.json.
|
||||||
* Members can appear in the UI from three sources (see TeamMemberResolver):
|
* Members can appear in the UI from three sources (see TeamMemberResolver):
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ export class TeamInboxReader {
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
||||||
.map((name) => name.replace(/\.json$/, ''));
|
.map((name) => name.replace(/\.json$/, ''))
|
||||||
|
.filter((name) => name !== '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {
|
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const tabId = useTabIdOptional();
|
const tabId = useTabIdOptional();
|
||||||
const {
|
const {
|
||||||
fetchPluginCatalog,
|
fetchPluginCatalog,
|
||||||
|
fetchCliStatus,
|
||||||
fetchApiKeys,
|
fetchApiKeys,
|
||||||
fetchSkillsCatalog,
|
fetchSkillsCatalog,
|
||||||
mcpBrowse,
|
mcpBrowse,
|
||||||
|
|
@ -40,11 +41,14 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
mcpBrowseLoading,
|
mcpBrowseLoading,
|
||||||
skillsLoading,
|
skillsLoading,
|
||||||
cliStatus,
|
cliStatus,
|
||||||
|
cliStatusLoading,
|
||||||
|
openDashboard,
|
||||||
sessions,
|
sessions,
|
||||||
projects,
|
projects,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
fetchPluginCatalog: s.fetchPluginCatalog,
|
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||||
|
fetchCliStatus: s.fetchCliStatus,
|
||||||
fetchApiKeys: s.fetchApiKeys,
|
fetchApiKeys: s.fetchApiKeys,
|
||||||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||||
mcpBrowse: s.mcpBrowse,
|
mcpBrowse: s.mcpBrowse,
|
||||||
|
|
@ -53,6 +57,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
mcpBrowseLoading: s.mcpBrowseLoading,
|
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||||
skillsLoading: s.skillsLoading,
|
skillsLoading: s.skillsLoading,
|
||||||
cliStatus: s.cliStatus,
|
cliStatus: s.cliStatus,
|
||||||
|
cliStatusLoading: s.cliStatusLoading,
|
||||||
|
openDashboard: s.openDashboard,
|
||||||
sessions: s.sessions,
|
sessions: s.sessions,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
}))
|
}))
|
||||||
|
|
@ -115,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
void fetchPluginCatalog(projectPath ?? undefined);
|
void fetchPluginCatalog(projectPath ?? undefined);
|
||||||
}, [fetchPluginCatalog, projectPath]);
|
}, [fetchPluginCatalog, projectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchCliStatus();
|
||||||
|
}, [fetchCliStatus]);
|
||||||
|
|
||||||
// Fetch MCP installed state on mount
|
// Fetch MCP installed state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void mcpFetchInstalled(projectPath ?? undefined);
|
void mcpFetchInstalled(projectPath ?? undefined);
|
||||||
|
|
@ -139,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
|
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
|
||||||
|
|
||||||
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||||
|
const cliStatusBanner = useMemo(() => {
|
||||||
|
if (cliStatusLoading || cliStatus === null) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
|
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cliStatus.installed) {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-300">Claude CLI is not available</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
|
Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to
|
||||||
|
install it and retry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||||
|
Open Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cliStatus.authLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-300">Claude CLI needs sign-in</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
|
Claude CLI was found
|
||||||
|
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
|
||||||
|
installs are disabled until you sign in from the Dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={openDashboard}>
|
||||||
|
Open Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-emerald-300">Claude CLI is ready</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
|
Plugins can be installed from this page
|
||||||
|
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [cliStatus, cliStatusLoading, openDashboard]);
|
||||||
|
|
||||||
// Browser mode guard
|
// Browser mode guard
|
||||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||||
|
|
@ -156,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{cliStatusBanner}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,25 @@ export const InstallButton = ({
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: InstallButtonProps) => {
|
}: InstallButtonProps) => {
|
||||||
const cliStatus = useStore(useShallow((s) => s.cliStatus));
|
const { cliStatus, cliStatusLoading } = useStore(
|
||||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
useShallow((s) => ({
|
||||||
const isDisabled = disabled || cliMissing;
|
cliStatus: s.cliStatus,
|
||||||
|
cliStatusLoading: s.cliStatusLoading,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const cliUnknown = cliStatus === null;
|
||||||
|
const cliMissing = cliStatus?.installed === false;
|
||||||
|
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn;
|
||||||
|
const disableReason = cliStatusLoading
|
||||||
|
? 'Checking Claude CLI status...'
|
||||||
|
: cliUnknown
|
||||||
|
? 'Checking Claude CLI availability...'
|
||||||
|
: cliMissing
|
||||||
|
? 'Claude CLI required. Install it from the Dashboard.'
|
||||||
|
: authMissing
|
||||||
|
? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'
|
||||||
|
: null;
|
||||||
|
const isDisabled = disabled || Boolean(disableReason);
|
||||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -92,23 +108,30 @@ export const InstallButton = ({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (errorMessage) {
|
const tooltipMessage = disableReason ?? errorMessage;
|
||||||
|
|
||||||
|
if (tooltipMessage) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<div className="flex max-w-64 flex-col items-end gap-1">
|
||||||
<Tooltip>
|
<TooltipProvider>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<span tabIndex={0}>{retryButton}</span>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<span tabIndex={0}>{retryButton}</span>
|
||||||
<TooltipContent className="max-w-64 text-red-300">{errorMessage}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent className="max-w-64 text-red-300">{tooltipMessage}</TooltipContent>
|
||||||
</TooltipProvider>
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
{errorMessage && !disableReason ? (
|
||||||
|
<p className="text-right text-[11px] leading-4 text-red-300">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return retryButton;
|
return retryButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
// idle — wrap in tooltip when CLI missing
|
// idle — wrap in tooltip when install is unavailable
|
||||||
const button = isInstalled ? (
|
const button = isInstalled ? (
|
||||||
<Button
|
<Button
|
||||||
size={size}
|
size={size}
|
||||||
|
|
@ -139,14 +162,14 @@ export const InstallButton = ({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cliMissing) {
|
if (disableReason) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span tabIndex={0}>{button}</span>
|
<span tabIndex={0}>{button}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Claude CLI required</TooltipContent>
|
<TooltipContent className="max-w-64">{disableReason}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Footer: author + version + install button */}
|
{/* Footer: author + version + install button */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
||||||
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
|
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
|
||||||
{plugin.version && (
|
{plugin.version && (
|
||||||
|
|
|
||||||
|
|
@ -777,17 +777,69 @@ export const TeamDetailView = ({
|
||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Live git branch polling for the team's project path
|
// Live git branch tracking for the lead project and member worktrees
|
||||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||||
const branchSyncPaths = useMemo(
|
const leadProjectPath = useMemo(() => {
|
||||||
() => (teamProjectPath ? [teamProjectPath] : []),
|
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||||
[teamProjectPath]
|
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||||
);
|
}, [data?.members, teamProjectPath]);
|
||||||
// Live branch sync now uses main-side background tracking instead of renderer polling.
|
const branchSyncPaths = useMemo(() => {
|
||||||
|
const uniquePaths = new Map<string, string>();
|
||||||
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
|
const trimmed = candidate?.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const key = normalizePath(trimmed);
|
||||||
|
if (!key || uniquePaths.has(key)) return;
|
||||||
|
uniquePaths.set(key, trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
addPath(leadProjectPath);
|
||||||
|
for (const member of data?.members ?? []) {
|
||||||
|
addPath(member.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(uniquePaths.values());
|
||||||
|
}, [data?.members, leadProjectPath]);
|
||||||
useBranchSync(branchSyncPaths, { live: true });
|
useBranchSync(branchSyncPaths, { live: true });
|
||||||
const leadBranch = useStore((s) =>
|
const trackedBranches = useStore(
|
||||||
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
|
useShallow((s) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
branchSyncPaths.map((projectPath) => {
|
||||||
|
const normalizedPath = normalizePath(projectPath);
|
||||||
|
return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
const leadBranch = leadProjectPath
|
||||||
|
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
|
||||||
|
: null;
|
||||||
|
const membersWithLiveBranches = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
return data.members.map((member) => {
|
||||||
|
const memberPath = member.cwd?.trim();
|
||||||
|
const nextGitBranch =
|
||||||
|
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||||
|
? (() => {
|
||||||
|
const branch = trackedBranches[normalizePath(memberPath)] ?? null;
|
||||||
|
return branch && branch !== leadBranch ? branch : undefined;
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (member.gitBranch === nextGitBranch) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMember: ResolvedTeamMember = { ...member };
|
||||||
|
if (nextGitBranch) {
|
||||||
|
nextMember.gitBranch = nextGitBranch;
|
||||||
|
} else {
|
||||||
|
delete nextMember.gitBranch;
|
||||||
|
}
|
||||||
|
return nextMember;
|
||||||
|
});
|
||||||
|
}, [data, leadBranch, trackedBranches]);
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessionIds = useMemo(() => {
|
const teamSessionIds = useMemo(() => {
|
||||||
|
|
@ -858,7 +910,7 @@ export const TeamDetailView = ({
|
||||||
return result;
|
return result;
|
||||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||||||
|
|
||||||
const activeMembers = useStableActiveMembers(data?.members);
|
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
|
||||||
|
|
||||||
const kanbanDisplayTasks = useMemo(() => {
|
const kanbanDisplayTasks = useMemo(() => {
|
||||||
const query = kanbanSearch.trim();
|
const query = kanbanSearch.trim();
|
||||||
|
|
@ -987,12 +1039,12 @@ export const TeamDetailView = ({
|
||||||
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingMemberProfile || !data) return;
|
if (!pendingMemberProfile || !data) return;
|
||||||
const member = data.members.find((m) => m.name === pendingMemberProfile);
|
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
|
||||||
if (member) {
|
if (member) {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
}
|
}
|
||||||
useStore.getState().closeMemberProfile();
|
useStore.getState().closeMemberProfile();
|
||||||
}, [pendingMemberProfile, data]);
|
}, [pendingMemberProfile, membersWithLiveBranches]);
|
||||||
|
|
||||||
const handleDeleteTask = useCallback(
|
const handleDeleteTask = useCallback(
|
||||||
(taskId: string) => {
|
(taskId: string) => {
|
||||||
|
|
@ -1610,7 +1662,7 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MemberList
|
<MemberList
|
||||||
members={data.members}
|
members={membersWithLiveBranches}
|
||||||
memberTaskCounts={memberTaskCounts}
|
memberTaskCounts={memberTaskCounts}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
|
|
@ -1977,7 +2029,7 @@ export const TeamDetailView = ({
|
||||||
currentName={data.config.name}
|
currentName={data.config.name}
|
||||||
currentDescription={data.config.description ?? ''}
|
currentDescription={data.config.description ?? ''}
|
||||||
currentColor={data.config.color ?? ''}
|
currentColor={data.config.color ?? ''}
|
||||||
currentMembers={data.members.filter((m) => !isLeadMember(m))}
|
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
onClose={() => setEditDialogOpen(false)}
|
onClose={() => setEditDialogOpen(false)}
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
|
|
@ -1986,8 +2038,8 @@ export const TeamDetailView = ({
|
||||||
<AddMemberDialog
|
<AddMemberDialog
|
||||||
open={addMemberDialogOpen}
|
open={addMemberDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
existingNames={data.members.map((m) => m.name)}
|
existingNames={membersWithLiveBranches.map((m) => m.name)}
|
||||||
existingMembers={data.members}
|
existingMembers={membersWithLiveBranches}
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
adding={addingMemberLoading}
|
adding={addingMemberLoading}
|
||||||
onClose={() => setAddMemberDialogOpen(false)}
|
onClose={() => setAddMemberDialogOpen(false)}
|
||||||
|
|
@ -2070,7 +2122,7 @@ export const TeamDetailView = ({
|
||||||
mode="launch"
|
mode="launch"
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={data?.members ?? []}
|
members={membersWithLiveBranches}
|
||||||
defaultProjectPath={data.config.projectPath}
|
defaultProjectPath={data.config.projectPath}
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
clearProvisioningError={clearProvisioningError}
|
clearProvisioningError={clearProvisioningError}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -143,6 +144,10 @@ function getSkillsCatalogKey(projectPath?: string): string {
|
||||||
|
|
||||||
/** Duration to show "success" state before returning to idle */
|
/** Duration to show "success" state before returning to idle */
|
||||||
const SUCCESS_DISPLAY_MS = 2_000;
|
const SUCCESS_DISPLAY_MS = 2_000;
|
||||||
|
const CLI_AUTH_REQUIRED_MESSAGE =
|
||||||
|
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
|
||||||
|
const CLI_STATUS_UNKNOWN_MESSAGE =
|
||||||
|
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||||
|
|
||||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||||
set,
|
set,
|
||||||
|
|
@ -552,8 +557,36 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
installPlugin: async (request: PluginInstallRequest) => {
|
installPlugin: async (request: PluginInstallRequest) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
|
const preflightState = get();
|
||||||
|
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||||
|
try {
|
||||||
|
await preflightState.fetchCliStatus();
|
||||||
|
} catch {
|
||||||
|
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliStatus = get().cliStatus;
|
||||||
|
const preflightError =
|
||||||
|
cliStatus === null
|
||||||
|
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||||
|
: !cliStatus.installed
|
||||||
|
? CLI_NOT_FOUND_MESSAGE
|
||||||
|
: !cliStatus.authLoggedIn
|
||||||
|
? CLI_AUTH_REQUIRED_MESSAGE
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (preflightError) {
|
||||||
|
set((prev) => ({
|
||||||
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||||
|
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,17 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
describe('installPlugin', () => {
|
describe('installPlugin', () => {
|
||||||
it('sets progress to pending then success', async () => {
|
it('sets progress to pending then success', async () => {
|
||||||
|
store.setState({
|
||||||
|
cliStatus: {
|
||||||
|
installed: true,
|
||||||
|
installedVersion: '1.0.0',
|
||||||
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
updateAvailable: false,
|
||||||
|
authLoggedIn: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
},
|
||||||
|
});
|
||||||
const plugins = [makePlugin({ pluginId: 'a@m' })];
|
const plugins = [makePlugin({ pluginId: 'a@m' })];
|
||||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
||||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
|
|
@ -276,6 +287,17 @@ describe('extensionsSlice', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets progress to error on failure', async () => {
|
it('sets progress to error on failure', async () => {
|
||||||
|
store.setState({
|
||||||
|
cliStatus: {
|
||||||
|
installed: true,
|
||||||
|
installedVersion: '1.0.0',
|
||||||
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
updateAvailable: false,
|
||||||
|
authLoggedIn: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
},
|
||||||
|
});
|
||||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: 'Not found',
|
error: 'Not found',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue