Merge branch 'dev' of https://github.com/777genius/claude_agent_teams_ui into dev
This commit is contained in:
commit
e5d85ec873
29 changed files with 1059 additions and 105 deletions
|
|
@ -26,7 +26,7 @@
|
|||
<sub>100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
|
||||
</p>
|
||||
|
||||
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/be19cfcb-93ff-403a-9a1e-8ff1a803c55e" />
|
||||
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/9d502887-7e28-4a11-aedd-3bd45fdfb0d2" />
|
||||
|
||||
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -485,7 +485,14 @@ export class FileWatcher extends EventEmitter {
|
|||
watcher: fs.FSWatcher,
|
||||
watcherType: 'projects' | 'todos' | 'teams' | 'tasks'
|
||||
): void {
|
||||
watcher.on('error', (error) => {
|
||||
watcher.on('error', (error: NodeJS.ErrnoException) => {
|
||||
// Ephemeral .lock files cause harmless ENOENT when the recursive watcher
|
||||
// tries to scandir a path that was already deleted. Log as debug and skip
|
||||
// the teardown/retry — the watcher is still healthy.
|
||||
if (error.code === 'ENOENT' && error.path?.endsWith('.lock')) {
|
||||
logger.debug(`FileWatcher: ${watcherType} ignoring transient ENOENT on lock file`);
|
||||
return;
|
||||
}
|
||||
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
|
||||
if (watcherType === 'projects') {
|
||||
this.projectsWatcher = null;
|
||||
|
|
|
|||
|
|
@ -213,13 +213,39 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
)
|
||||
);
|
||||
const members = params.members ?? {};
|
||||
const launchPhase = params.launchPhase ?? 'active';
|
||||
|
||||
// When the launch is over (finished/reconciled), members still in 'starting' state
|
||||
// (never spawned — agentToolAccepted is false) are unreachable and should be marked
|
||||
// as failed. Without this, they stay as 'pending' forever, causing the UI to show
|
||||
// "Last launch is still reconciling" indefinitely after a crash or incomplete launch.
|
||||
if (launchPhase !== 'active') {
|
||||
for (const name of expectedMembers) {
|
||||
const member = members[name];
|
||||
if (
|
||||
member &&
|
||||
member.launchState === 'starting' &&
|
||||
!member.agentToolAccepted &&
|
||||
!member.runtimeAlive &&
|
||||
!member.bootstrapConfirmed &&
|
||||
!member.hardFailure
|
||||
) {
|
||||
member.hardFailure = true;
|
||||
member.hardFailureReason =
|
||||
member.hardFailureReason ?? 'Teammate was never spawned during launch.';
|
||||
member.launchState = deriveMemberLaunchState(member);
|
||||
member.diagnostics = buildDiagnostics(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizePersistedLaunchMembers(expectedMembers, members);
|
||||
return {
|
||||
version: 2,
|
||||
teamName: params.teamName,
|
||||
updatedAt,
|
||||
...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}),
|
||||
launchPhase: params.launchPhase ?? 'active',
|
||||
launchPhase,
|
||||
expectedMembers,
|
||||
members,
|
||||
summary,
|
||||
|
|
|
|||
|
|
@ -4860,11 +4860,59 @@ export class TeamProvisioningService {
|
|||
// so the lead retains full context of prior work.
|
||||
// When clearContext is true, skip resume entirely to start a fresh session.
|
||||
let previousSessionId: string | undefined;
|
||||
let skipResume = false;
|
||||
if (request.clearContext) {
|
||||
skipResume = true;
|
||||
logger.info(
|
||||
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh`
|
||||
);
|
||||
} else {
|
||||
// Check persisted launch state: if the previous launch ended with no teammates
|
||||
// ever spawned (all in 'starting' state), resuming would reconnect the lead but
|
||||
// the CLI's deterministic bootstrap won't re-spawn dead teammates in reconnect
|
||||
// mode. Skip resume so the CLI creates a fresh session that fully bootstraps.
|
||||
const persistedLaunchState = await this.launchStateStore.read(request.teamName);
|
||||
if (persistedLaunchState) {
|
||||
const {
|
||||
expectedMembers: prevExpected,
|
||||
members: prevMembers,
|
||||
launchPhase,
|
||||
} = persistedLaunchState;
|
||||
const teammateWasNeverSpawned = (
|
||||
member:
|
||||
| {
|
||||
agentToolAccepted?: boolean;
|
||||
firstSpawnAcceptedAt?: string;
|
||||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
}
|
||||
| undefined
|
||||
): boolean => {
|
||||
if (!member) return true;
|
||||
const hasAcceptedSpawn =
|
||||
member.agentToolAccepted === true ||
|
||||
(typeof member.firstSpawnAcceptedAt === 'string' &&
|
||||
member.firstSpawnAcceptedAt.trim().length > 0);
|
||||
return (
|
||||
!hasAcceptedSpawn &&
|
||||
member.runtimeAlive !== true &&
|
||||
member.bootstrapConfirmed !== true
|
||||
);
|
||||
};
|
||||
const allTeammatesNeverSpawned =
|
||||
launchPhase !== 'active' &&
|
||||
prevExpected.length > 0 &&
|
||||
prevExpected.every((name) => teammateWasNeverSpawned(prevMembers[name]));
|
||||
if (allTeammatesNeverSpawned) {
|
||||
skipResume = true;
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous launch had no teammates successfully spawned — ` +
|
||||
`skipping session resume to allow full bootstrap`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!skipResume) {
|
||||
try {
|
||||
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
|
||||
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
openDashboard,
|
||||
sessions,
|
||||
projects,
|
||||
repositoryGroups,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||
|
|
@ -61,6 +63,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
openDashboard: s.openDashboard,
|
||||
sessions: s.sessions,
|
||||
projects: s.projects,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
}))
|
||||
);
|
||||
const cliInstalled = cliStatus?.installed ?? true;
|
||||
|
|
@ -74,14 +77,12 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
|
||||
const tabState = useExtensionsTabState();
|
||||
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
||||
const projectPath = useMemo(
|
||||
() => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null,
|
||||
[extensionsTabProjectId, projects]
|
||||
);
|
||||
const projectLabel = useMemo(
|
||||
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
|
||||
[extensionsTabProjectId, projects]
|
||||
const resolvedProject = useMemo(
|
||||
() => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups),
|
||||
[extensionsTabProjectId, projects, repositoryGroups]
|
||||
);
|
||||
const projectPath = resolvedProject?.path ?? null;
|
||||
const projectLabel = resolvedProject?.name ?? null;
|
||||
const subTabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -575,6 +575,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
multimodelEnabled,
|
||||
storedProviderId,
|
||||
storedEffort: storedEffort === null ? 'medium' : storedEffort,
|
||||
storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true',
|
||||
getStoredModel: getStoredTeamModel,
|
||||
});
|
||||
setSavedLaunchProviderId(savedProviderId);
|
||||
|
|
@ -590,10 +591,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setSelectedProviderIdRaw(launchPrefill.providerId);
|
||||
setSelectedModelRaw(launchPrefill.model);
|
||||
setSelectedEffortRaw(launchPrefill.effort);
|
||||
setLimitContextRaw(
|
||||
savedRequest?.limitContext === true ||
|
||||
localStorage.getItem('team:lastLimitContext') === 'true'
|
||||
);
|
||||
setLimitContextRaw(launchPrefill.limitContext);
|
||||
setSkipPermissionsRaw(
|
||||
savedRequest?.skipPermissions ??
|
||||
localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
normalizeTeamModelForUi,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
|
||||
|
|
@ -99,8 +100,11 @@ export function computeEffectiveTeamModel(
|
|||
limitContext: boolean,
|
||||
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
|
||||
): string | undefined {
|
||||
const base = selectedModel || undefined;
|
||||
if (providerId !== 'anthropic') return base;
|
||||
if (providerId !== 'anthropic') {
|
||||
return selectedModel.trim() || undefined;
|
||||
}
|
||||
|
||||
const base = extractProviderScopedBaseModel(selectedModel, providerId);
|
||||
if (limitContext) return base;
|
||||
if (base === 'haiku') return base;
|
||||
return base ? `${base}[1m]` : 'opus[1m]';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ interface PreviousLaunchParamsLike {
|
|||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
limitContext?: boolean;
|
||||
}
|
||||
|
||||
interface LaunchDialogPrefillInput {
|
||||
|
|
@ -18,6 +20,7 @@ interface LaunchDialogPrefillInput {
|
|||
multimodelEnabled: boolean;
|
||||
storedProviderId: TeamProviderId;
|
||||
storedEffort: string;
|
||||
storedLimitContext: boolean;
|
||||
getStoredModel: (providerId: TeamProviderId) => string;
|
||||
}
|
||||
|
||||
|
|
@ -25,14 +28,18 @@ interface LaunchDialogPrefillResult {
|
|||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort: string;
|
||||
limitContext: boolean;
|
||||
}
|
||||
|
||||
function normalizeModelCandidate(model: string | undefined): string {
|
||||
function normalizeModelCandidate(
|
||||
model: string | undefined,
|
||||
providerId: TeamProviderId | undefined
|
||||
): string {
|
||||
const trimmed = model?.trim() ?? '';
|
||||
if (!trimmed || trimmed === 'default' || trimmed === '__default__') {
|
||||
return '';
|
||||
}
|
||||
return trimmed;
|
||||
return extractProviderScopedBaseModel(trimmed, providerId) ?? '';
|
||||
}
|
||||
|
||||
function canReuseModelForSelectedProvider(
|
||||
|
|
@ -52,6 +59,7 @@ export function resolveLaunchDialogPrefill({
|
|||
multimodelEnabled,
|
||||
storedProviderId,
|
||||
storedEffort,
|
||||
storedLimitContext,
|
||||
getStoredModel,
|
||||
}: LaunchDialogPrefillInput): LaunchDialogPrefillResult {
|
||||
const currentLead = members.find((member) => isLeadMember(member));
|
||||
|
|
@ -69,15 +77,15 @@ export function resolveLaunchDialogPrefill({
|
|||
const modelCandidates = [
|
||||
{
|
||||
providerId: currentLeadProviderId,
|
||||
model: normalizeModelCandidate(currentLead?.model),
|
||||
model: normalizeModelCandidate(currentLead?.model, currentLeadProviderId),
|
||||
},
|
||||
{
|
||||
providerId: savedRequestProviderId,
|
||||
model: normalizeModelCandidate(savedRequest?.model),
|
||||
model: normalizeModelCandidate(savedRequest?.model, savedRequestProviderId),
|
||||
},
|
||||
{
|
||||
providerId: previousLaunchProviderId,
|
||||
model: normalizeModelCandidate(previousLaunchParams?.model),
|
||||
model: normalizeModelCandidate(previousLaunchParams?.model, previousLaunchProviderId),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -88,6 +96,8 @@ export function resolveLaunchDialogPrefill({
|
|||
|
||||
const effort =
|
||||
currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort;
|
||||
const limitContext =
|
||||
previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
|
|
@ -95,5 +105,6 @@ export function resolveLaunchDialogPrefill({
|
|||
? normalizeTeamModelForUi(providerId, matchingModel)
|
||||
: getStoredModel(providerId),
|
||||
effort,
|
||||
limitContext,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,19 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
|
||||
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
|
||||
import { parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -205,20 +211,35 @@ export const MessageComposer = ({
|
|||
})),
|
||||
[members, colorMap]
|
||||
);
|
||||
const leadProviderId = useMemo(() => {
|
||||
const lead = members.find((member) => isLeadMember(member));
|
||||
return (
|
||||
normalizeOptionalTeamProviderId(lead?.providerId) ?? inferTeamProviderIdFromModel(lead?.model)
|
||||
);
|
||||
}, [members]);
|
||||
|
||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
||||
// Project skills as slash command suggestions
|
||||
const projectSkills = useStore(
|
||||
useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
|
||||
);
|
||||
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
|
||||
// Fetch skills catalog for the team's project on mount / project change
|
||||
useEffect(() => {
|
||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||
}, [fetchSkillsCatalog, projectPath]);
|
||||
|
||||
const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
KNOWN_SLASH_COMMANDS.map((command) => ({
|
||||
id: `command:${command.name}`,
|
||||
name: command.name,
|
||||
command: command.command,
|
||||
description: command.description,
|
||||
subtitle: command.description,
|
||||
type: 'command',
|
||||
})),
|
||||
[]
|
||||
buildSlashCommandSuggestions(
|
||||
getSuggestedSlashCommandsForProvider(leadProviderId),
|
||||
projectSkills,
|
||||
userSkills
|
||||
),
|
||||
[leadProviderId, projectSkills, userSkills]
|
||||
);
|
||||
|
||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDeta
|
|||
...detail,
|
||||
logDetail: {
|
||||
...detail.logDetail,
|
||||
chunks: asEnhancedChunkArray(detail.logDetail.chunks),
|
||||
chunks: asEnhancedChunkArray(detail.logDetail.chunks) ?? detail.logDetail.chunks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,11 +83,12 @@ export const MentionSuggestionList = ({
|
|||
}
|
||||
|
||||
// Categorize suggestions (folders are grouped with files)
|
||||
type Section = 'member' | 'team' | 'task' | 'file' | 'command';
|
||||
type Section = 'member' | 'team' | 'task' | 'file' | 'command' | 'skill';
|
||||
const getSuggestionSection = (s: MentionSuggestion): Section => {
|
||||
if (s.type === 'file' || s.type === 'folder') return 'file';
|
||||
if (s.type === 'task') return 'task';
|
||||
if (s.type === 'command') return 'command';
|
||||
if (s.type === 'skill') return 'skill';
|
||||
if (s.type === 'team') return 'team';
|
||||
return 'member';
|
||||
};
|
||||
|
|
@ -98,6 +99,7 @@ export const MentionSuggestionList = ({
|
|||
task: 'Tasks',
|
||||
file: 'Files',
|
||||
command: 'Commands',
|
||||
skill: 'Skills',
|
||||
};
|
||||
|
||||
// Determine which sections are present
|
||||
|
|
@ -117,6 +119,7 @@ export const MentionSuggestionList = ({
|
|||
const isTeam = section === 'team';
|
||||
const isTask = section === 'task';
|
||||
const isCommand = section === 'command';
|
||||
const isSkill = section === 'skill';
|
||||
const taskTeamColorSet =
|
||||
isTask && s.color
|
||||
? getTeamColorSet(s.color)
|
||||
|
|
@ -165,6 +168,8 @@ export const MentionSuggestionList = ({
|
|||
<Hash size={13} className="shrink-0 text-blue-500 dark:text-blue-400" />
|
||||
) : isCommand ? (
|
||||
<Command size={13} className="shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
) : isSkill ? (
|
||||
<Command size={13} className="shrink-0 text-cyan-500 dark:text-cyan-400" />
|
||||
) : isTeam ? (
|
||||
<UsersRound
|
||||
size={13}
|
||||
|
|
@ -188,13 +193,21 @@ export const MentionSuggestionList = ({
|
|||
? { color: 'var(--color-link, #60a5fa)' }
|
||||
: isCommand
|
||||
? { color: 'rgb(245 158 11)' }
|
||||
: isSkill
|
||||
? { color: 'rgb(6 182 212)' }
|
||||
: colorSet
|
||||
? { color: getThemedText(colorSet, isLight) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<HighlightedName
|
||||
name={isTask ? `#${s.name}` : isCommand ? (s.command ?? `/${s.name}`) : s.name}
|
||||
name={
|
||||
isTask
|
||||
? `#${s.name}`
|
||||
: isCommand || isSkill
|
||||
? (s.command ?? `/${s.name}`)
|
||||
: s.name
|
||||
}
|
||||
query={query}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -218,7 +231,7 @@ export const MentionSuggestionList = ({
|
|||
{isTask && s.subtitle ? (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{s.subtitle}</div>
|
||||
) : null}
|
||||
{isCommand && s.description ? (
|
||||
{(isCommand || isSkill) && s.description ? (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{s.description}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { api } from '@renderer/api';
|
|||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeySaveRequest,
|
||||
|
|
@ -889,9 +890,24 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// ── Tab opener ──
|
||||
openExtensionsTab: () => {
|
||||
const state = get();
|
||||
const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined;
|
||||
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
||||
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
|
||||
if (existingTab) {
|
||||
// Update projectId to reflect the currently selected project
|
||||
if (existingTab.projectId !== currentProjectId) {
|
||||
const pane = findPaneByTabId(state.paneLayout, existingTab.id);
|
||||
if (pane) {
|
||||
set({
|
||||
paneLayout: updatePane(state.paneLayout, {
|
||||
...pane,
|
||||
tabs: pane.tabs.map((t) =>
|
||||
t.id === existingTab.id ? { ...t, projectId: currentProjectId } : t
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
state.setActiveTab(existingTab.id);
|
||||
return;
|
||||
}
|
||||
|
|
@ -899,7 +915,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
state.openTab({
|
||||
type: 'extensions',
|
||||
label: 'Extensions',
|
||||
projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined,
|
||||
projectId: currentProjectId,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -6,15 +6,51 @@ import {
|
|||
canDisplayTaskChangesForOptions,
|
||||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
AddMemberRequest,
|
||||
AddTaskCommentRequest,
|
||||
CreateTaskRequest,
|
||||
CrossTeamSendRequest,
|
||||
EffortLevel,
|
||||
GlobalTask,
|
||||
InboxMessage,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
TeamCreateRequest,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamProviderId,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalSettings,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('teamSlice');
|
||||
|
||||
|
|
@ -483,42 +519,6 @@ async function pollProvisioningStatus(
|
|||
}
|
||||
}
|
||||
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
AddMemberRequest,
|
||||
AddTaskCommentRequest,
|
||||
CreateTaskRequest,
|
||||
CrossTeamSendRequest,
|
||||
EffortLevel,
|
||||
GlobalTask,
|
||||
InboxMessage,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
TeamCreateRequest,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalSettings,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// --- Clarification notification tracking ---
|
||||
// Native OS notifications for new inbox messages are handled in main process
|
||||
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
|
||||
|
|
@ -1219,9 +1219,8 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void {
|
|||
* Extract the base model name from the raw model string sent to CLI.
|
||||
* E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined.
|
||||
*/
|
||||
function extractBaseModel(raw?: string): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/\[1m\]$/, '') || undefined;
|
||||
function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | undefined {
|
||||
return extractProviderScopedBaseModel(raw, providerId);
|
||||
}
|
||||
|
||||
const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:';
|
||||
|
|
@ -2587,7 +2586,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
|
||||
|
||||
// Persist per-team launch params (model, effort, limit context)
|
||||
const baseModel = extractBaseModel(request.model);
|
||||
const baseModel = extractBaseModel(request.model, request.providerId);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
model: baseModel || 'default',
|
||||
|
|
@ -2767,7 +2766,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
||||
|
||||
// Persist per-team launch params (model, effort, limit context)
|
||||
const baseModel = extractBaseModel(request.model);
|
||||
const baseModel = extractBaseModel(request.model, request.providerId);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
model: baseModel || 'default',
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ export interface MentionSuggestion {
|
|||
description?: string;
|
||||
/** Color name from TeamColorSet palette */
|
||||
color?: string;
|
||||
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */
|
||||
type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command';
|
||||
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */
|
||||
type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill';
|
||||
/** Whether the team is currently online (team suggestions only) */
|
||||
isOnline?: boolean;
|
||||
/** Absolute file/folder path (file/folder suggestions only) */
|
||||
|
|
@ -21,7 +21,7 @@ export interface MentionSuggestion {
|
|||
insertText?: string;
|
||||
/** Optional extra searchable text (subject, team name, path, etc.) */
|
||||
searchText?: string;
|
||||
/** Optional slash command string including leading slash (command suggestions only) */
|
||||
/** Optional slash command string including leading slash (command and skill suggestions only) */
|
||||
command?: `/${string}`;
|
||||
/** Canonical task id (task suggestions only) */
|
||||
taskId?: string;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
|||
|
||||
export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' {
|
||||
if (suggestion.type === 'task') return '#';
|
||||
if (suggestion.type === 'command') return '/';
|
||||
if (suggestion.type === 'command' || suggestion.type === 'skill') return '/';
|
||||
return '@';
|
||||
}
|
||||
|
||||
export function getSuggestionInsertionText(suggestion: MentionSuggestion): string {
|
||||
if (suggestion.type === 'command') {
|
||||
if (suggestion.type === 'command' || suggestion.type === 'skill') {
|
||||
return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name;
|
||||
}
|
||||
return suggestion.insertText ?? suggestion.name;
|
||||
|
|
|
|||
|
|
@ -35,3 +35,30 @@ export function resolveProjectIdByPath(
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a filesystem path from an encoded project ID.
|
||||
*
|
||||
* Inverse of `resolveProjectIdByPath`: given a project ID (e.g. `-home-diego-DEV-monorepo-rCAPI`),
|
||||
* returns the decoded path (e.g. `/home/diego/DEV/monorepo/rCAPI`).
|
||||
*
|
||||
* Checks both `projects[]` and `repositoryGroups[].worktrees[]` so the result
|
||||
* is correct regardless of whether the sidebar is in flat or grouped view mode.
|
||||
*/
|
||||
export function resolveProjectPathById(
|
||||
projectId: string | undefined | null,
|
||||
projects: readonly Pick<Project, 'id' | 'path' | 'name'>[],
|
||||
repositoryGroups: readonly Pick<RepositoryGroup, 'worktrees'>[]
|
||||
): { path: string; name: string } | null {
|
||||
if (!projectId) return null;
|
||||
|
||||
const fromProjects = projects.find((p) => p.id === projectId);
|
||||
if (fromProjects) return { path: fromProjects.path, name: fromProjects.name };
|
||||
|
||||
for (const group of repositoryGroups) {
|
||||
const worktree = group.worktrees.find((w) => w.id === projectId);
|
||||
if (worktree) return { path: worktree.path, name: worktree.name };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
122
src/renderer/utils/providerSlashCommands.ts
Normal file
122
src/renderer/utils/providerSlashCommands.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
|
||||
|
||||
const CODEX_SLASH_COMMAND_SUGGESTIONS: readonly KnownSlashCommandDefinition[] = [
|
||||
{
|
||||
name: 'model',
|
||||
command: '/model',
|
||||
description: 'Choose the active model for this session.',
|
||||
},
|
||||
{
|
||||
name: 'fast',
|
||||
command: '/fast',
|
||||
description: 'Toggle Fast mode on or off.',
|
||||
},
|
||||
{
|
||||
name: 'permissions',
|
||||
command: '/permissions',
|
||||
description: 'Adjust approval requirements for tools and commands.',
|
||||
},
|
||||
{
|
||||
name: 'plan',
|
||||
command: '/plan',
|
||||
description: 'Switch to plan mode with an optional prompt.',
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
command: '/review',
|
||||
description: 'Ask Codex to review the current working tree.',
|
||||
},
|
||||
{
|
||||
name: 'diff',
|
||||
command: '/diff',
|
||||
description: 'Show the current Git diff, including untracked files.',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
command: '/status',
|
||||
description: 'Show session configuration and token usage.',
|
||||
},
|
||||
{
|
||||
name: 'mcp',
|
||||
command: '/mcp',
|
||||
description: 'List configured MCP tools for this session.',
|
||||
},
|
||||
{
|
||||
name: 'mention',
|
||||
command: '/mention',
|
||||
description: 'Attach a file or folder to the conversation.',
|
||||
},
|
||||
{
|
||||
name: 'apps',
|
||||
command: '/apps',
|
||||
description: 'Browse available apps and connectors.',
|
||||
},
|
||||
{
|
||||
name: 'plugins',
|
||||
command: '/plugins',
|
||||
description: 'Browse and manage installed plugins.',
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
command: '/agent',
|
||||
description: 'Switch to another agent thread.',
|
||||
},
|
||||
{
|
||||
name: 'personality',
|
||||
command: '/personality',
|
||||
description: 'Change Codex response style for the current thread.',
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
command: '/compact',
|
||||
description: 'Summarize the conversation to free tokens.',
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
command: '/clear',
|
||||
description: 'Clear the terminal and start a fresh chat.',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
command: '/new',
|
||||
description: 'Start a new conversation in the current session.',
|
||||
},
|
||||
{
|
||||
name: 'copy',
|
||||
command: '/copy',
|
||||
description: 'Copy the latest completed Codex output.',
|
||||
},
|
||||
{
|
||||
name: 'fork',
|
||||
command: '/fork',
|
||||
description: 'Fork the current conversation into a new thread.',
|
||||
},
|
||||
{
|
||||
name: 'resume',
|
||||
command: '/resume',
|
||||
description: 'Resume a previous conversation.',
|
||||
},
|
||||
{
|
||||
name: 'quit',
|
||||
command: '/quit',
|
||||
description: 'Exit the CLI.',
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
command: '/exit',
|
||||
description: 'Exit the CLI.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getSuggestedSlashCommandsForProvider(
|
||||
providerId?: TeamProviderId
|
||||
): readonly KnownSlashCommandDefinition[] {
|
||||
if (providerId === 'codex') {
|
||||
return CODEX_SLASH_COMMAND_SUGGESTIONS;
|
||||
}
|
||||
|
||||
return KNOWN_SLASH_COMMANDS;
|
||||
}
|
||||
49
src/renderer/utils/skillCommandSuggestions.ts
Normal file
49
src/renderer/utils/skillCommandSuggestions.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
|
||||
|
||||
export function buildSlashCommandSuggestions(
|
||||
builtIns: readonly KnownSlashCommandDefinition[],
|
||||
projectSkills: readonly SkillCatalogItem[],
|
||||
userSkills: readonly SkillCatalogItem[]
|
||||
): MentionSuggestion[] {
|
||||
const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
|
||||
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
|
||||
id: `command:${command.name}`,
|
||||
name: command.name,
|
||||
command: command.command,
|
||||
description: command.description,
|
||||
subtitle: command.description,
|
||||
type: 'command',
|
||||
}));
|
||||
|
||||
const seenSkillNames = new Set<string>();
|
||||
const skillSuggestions: MentionSuggestion[] = [];
|
||||
for (const skill of [...projectSkills, ...userSkills]) {
|
||||
const normalizedFolderName = skill.folderName.trim().toLowerCase();
|
||||
if (
|
||||
!skill.isValid ||
|
||||
!normalizedFolderName ||
|
||||
!isSupportedSlashCommandName(normalizedFolderName) ||
|
||||
builtInNames.has(normalizedFolderName) ||
|
||||
seenSkillNames.has(normalizedFolderName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenSkillNames.add(normalizedFolderName);
|
||||
skillSuggestions.push({
|
||||
id: `skill:${skill.id}`,
|
||||
name: skill.folderName,
|
||||
command: `/${normalizedFolderName}`,
|
||||
description: skill.description,
|
||||
subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill',
|
||||
searchText: `${skill.name} ${skill.folderName}`,
|
||||
type: 'skill',
|
||||
});
|
||||
}
|
||||
|
||||
return [...builtInSuggestions, ...skillSuggestions];
|
||||
}
|
||||
32
src/renderer/utils/teamModelContext.ts
Normal file
32
src/renderer/utils/teamModelContext.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed.replace(/(?:\[1m\])+$/, '') || undefined;
|
||||
}
|
||||
|
||||
export function extractProviderScopedBaseModel(
|
||||
model: string | undefined,
|
||||
providerId?: TeamProviderId
|
||||
): string | undefined {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const effectiveProviderId =
|
||||
providerId ??
|
||||
inferTeamProviderIdFromModel(trimmed) ??
|
||||
inferTeamProviderIdFromModel(stripTrailingOneMillionSuffixes(trimmed));
|
||||
if (effectiveProviderId !== 'anthropic') {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return stripTrailingOneMillionSuffixes(trimmed);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand {
|
|||
endIndex: number;
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9:-]{0,63}$/i;
|
||||
const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i;
|
||||
|
||||
export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [
|
||||
|
|
@ -78,6 +79,10 @@ export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition
|
|||
return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
export function isSupportedSlashCommandName(name: string): boolean {
|
||||
return SLASH_COMMAND_NAME_PATTERN.test(name.trim());
|
||||
}
|
||||
|
||||
export function buildSlashCommandMeta(
|
||||
name: string,
|
||||
args?: string,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|||
});
|
||||
|
||||
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { spawnCli } from '@main/utils/childProcess';
|
||||
import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller';
|
||||
|
|
@ -84,6 +86,63 @@ function createRunningChild() {
|
|||
});
|
||||
}
|
||||
|
||||
function writeLaunchConfig(
|
||||
teamName: string,
|
||||
projectPath: string,
|
||||
leadSessionId: string,
|
||||
members: string[]
|
||||
): void {
|
||||
const teamDir = path.join(tempTeamsBase, teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath,
|
||||
leadSessionId,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
...members.map((name) => ({ name })),
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function writeLaunchState(
|
||||
teamName: string,
|
||||
leadSessionId: string,
|
||||
members: Record<string, Record<string, unknown>>
|
||||
): void {
|
||||
const snapshot = createPersistedLaunchSnapshot({
|
||||
teamName,
|
||||
leadSessionId,
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: Object.keys(members),
|
||||
members: Object.fromEntries(
|
||||
Object.entries(members).map(([name, member]) => [
|
||||
name,
|
||||
{
|
||||
name,
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
lastEvaluatedAt: new Date().toISOString(),
|
||||
...member,
|
||||
},
|
||||
])
|
||||
) as any,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
getTeamLaunchStatePath(teamName),
|
||||
`${JSON.stringify(snapshot, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
describe('TeamProvisioningService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -100,7 +159,6 @@ describe('TeamProvisioningService', () => {
|
|||
fs.mkdirSync(tempProjectsBase, { recursive: true });
|
||||
});
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
|
|
@ -389,14 +447,12 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
it('expands teammate permission suggestions to the operational tool set only', async () => {
|
||||
allowConsoleLogs();
|
||||
const svc = new TeamProvisioningService(
|
||||
{
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: tempClaudeRoot,
|
||||
members: [{ cwd: tempClaudeRoot }],
|
||||
})),
|
||||
} as any
|
||||
);
|
||||
const svc = new TeamProvisioningService({
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: tempClaudeRoot,
|
||||
members: [{ cwd: tempClaudeRoot }],
|
||||
})),
|
||||
} as any);
|
||||
|
||||
await (svc as any).respondToTeammatePermission(
|
||||
{ teamName: 'ops-team' },
|
||||
|
|
@ -427,14 +483,12 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
it('does not broaden admin/runtime teammate permission suggestions', async () => {
|
||||
allowConsoleLogs();
|
||||
const svc = new TeamProvisioningService(
|
||||
{
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: tempClaudeRoot,
|
||||
members: [{ cwd: tempClaudeRoot }],
|
||||
})),
|
||||
} as any
|
||||
);
|
||||
const svc = new TeamProvisioningService({
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: tempClaudeRoot,
|
||||
members: [{ cwd: tempClaudeRoot }],
|
||||
})),
|
||||
} as any);
|
||||
|
||||
await (svc as any).respondToTeammatePermission(
|
||||
{ teamName: 'ops-team' },
|
||||
|
|
@ -516,4 +570,107 @@ describe('TeamProvisioningService', () => {
|
|||
})
|
||||
).toBe('Questions (2): First question with extra spacing.');
|
||||
});
|
||||
|
||||
it('skips --resume when the persisted launch state shows no teammate ever spawned', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'resume-skip-team';
|
||||
const leadSessionId = 'lead-session-skip';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']);
|
||||
writeLaunchState(teamName, leadSessionId, {
|
||||
alice: {
|
||||
launchState: 'failed_to_start',
|
||||
},
|
||||
bob: {
|
||||
launchState: 'starting',
|
||||
hardFailure: false,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockImplementation(() => {
|
||||
throw new Error('launch spawn EINVAL');
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
|
||||
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
||||
'launch spawn EINVAL'
|
||||
);
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toBeTruthy();
|
||||
expect(launchArgs).not.toContain('--resume');
|
||||
expect(launchArgs).not.toContain(leadSessionId);
|
||||
});
|
||||
|
||||
it('keeps --resume when a teammate had an accepted spawn before failing bootstrap', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'resume-keep-team';
|
||||
const leadSessionId = 'lead-session-keep';
|
||||
const acceptedAt = '2026-04-14T12:00:00.000Z';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||
writeLaunchState(teamName, leadSessionId, {
|
||||
alice: {
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
firstSpawnAcceptedAt: acceptedAt,
|
||||
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockImplementation(() => {
|
||||
throw new Error('launch spawn EINVAL');
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
|
||||
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
||||
'launch spawn EINVAL'
|
||||
);
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toContain('--resume');
|
||||
expect(launchArgs).toContain(leadSessionId);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import {
|
||||
computeEffectiveTeamModel,
|
||||
formatTeamModelSummary,
|
||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import {
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||
|
|
@ -36,3 +39,35 @@ describe('formatTeamModelSummary', () => {
|
|||
expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEffectiveTeamModel', () => {
|
||||
it('appends [1m] for anthropic models', () => {
|
||||
expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]');
|
||||
});
|
||||
|
||||
it('does not double-append [1m] when input already has it', () => {
|
||||
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');
|
||||
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||
});
|
||||
|
||||
it('defaults to opus[1m] when no model selected', () => {
|
||||
expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
|
||||
});
|
||||
|
||||
it('returns base model without [1m] when limitContext is true', () => {
|
||||
expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus');
|
||||
});
|
||||
|
||||
it('returns haiku as-is', () => {
|
||||
expect(computeEffectiveTeamModel('haiku', false, 'anthropic')).toBe('haiku');
|
||||
});
|
||||
|
||||
it('returns non-anthropic models as-is', () => {
|
||||
expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4');
|
||||
expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
multimodelEnabled: true,
|
||||
storedProviderId: 'anthropic',
|
||||
storedEffort: 'medium',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'haiku',
|
||||
codex: 'gpt-5.4',
|
||||
|
|
@ -48,6 +49,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -64,7 +66,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
|
||||
const savedRequest = {
|
||||
teamName: 'vector-room-2',
|
||||
cwd: '/tmp/project',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
|
|
@ -78,6 +80,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
multimodelEnabled: true,
|
||||
storedProviderId: 'anthropic',
|
||||
storedEffort: 'medium',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'haiku',
|
||||
codex: 'gpt-5.4',
|
||||
|
|
@ -88,6 +91,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -103,6 +107,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
multimodelEnabled: true,
|
||||
storedProviderId: 'anthropic',
|
||||
storedEffort: 'medium',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'haiku',
|
||||
codex: 'gpt-5.4',
|
||||
|
|
@ -113,6 +118,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
providerId: 'codex',
|
||||
model: 'gpt-5.3-codex',
|
||||
effort: 'high',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -134,6 +140,7 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
multimodelEnabled: true,
|
||||
storedProviderId: 'anthropic',
|
||||
storedEffort: 'medium',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'haiku',
|
||||
codex: 'gpt-5.4',
|
||||
|
|
@ -144,6 +151,61 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers per-team launch params for limitContext over stale global storage', () => {
|
||||
const result = resolveLaunchDialogPrefill({
|
||||
members: [],
|
||||
savedRequest: null,
|
||||
previousLaunchParams: {
|
||||
providerId: 'anthropic',
|
||||
model: 'opus[1m][1m]',
|
||||
effort: 'high',
|
||||
limitContext: true,
|
||||
},
|
||||
multimodelEnabled: true,
|
||||
storedProviderId: 'anthropic',
|
||||
storedEffort: 'medium',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'haiku',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
providerId: 'anthropic',
|
||||
model: 'opus',
|
||||
effort: 'high',
|
||||
limitContext: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves literal [1m] suffixes for non-anthropic providers', () => {
|
||||
const result = resolveLaunchDialogPrefill({
|
||||
members: [],
|
||||
savedRequest: null,
|
||||
previousLaunchParams: {
|
||||
providerId: 'codex',
|
||||
model: 'custom-model[1m]',
|
||||
effort: 'medium',
|
||||
},
|
||||
multimodelEnabled: true,
|
||||
storedProviderId: 'anthropic',
|
||||
storedEffort: 'medium',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'haiku',
|
||||
codex: 'gpt-5.4',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
providerId: 'codex',
|
||||
model: 'custom-model[1m]',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ describe('TaskLogStreamSection integration', () => {
|
|||
expect(text).toContain('Grep');
|
||||
expect(text).toContain('Edit');
|
||||
expect(text).toContain('Claude');
|
||||
expect(text).toContain('2 tool calls');
|
||||
expect(text).toContain('3 tool calls');
|
||||
expect(text).toContain('Audit complete');
|
||||
expect(text).not.toContain('[]');
|
||||
expect(text).not.toContain('lead session');
|
||||
|
|
|
|||
|
|
@ -258,6 +258,42 @@ describe('extensionsSlice', () => {
|
|||
expect(count1).toBe(1);
|
||||
expect(count2).toBe(1); // no duplicate
|
||||
});
|
||||
|
||||
it('updates projectId on existing tab when selected project changes', () => {
|
||||
// Open Extensions with project-A
|
||||
store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
|
||||
store.getState().openExtensionsTab();
|
||||
|
||||
const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
|
||||
const extTabBefore = tabsBefore.find((t) => t.type === 'extensions');
|
||||
expect(extTabBefore?.projectId).toBe('project-A');
|
||||
|
||||
// Switch to project-B and reopen Extensions
|
||||
store.setState({ selectedProjectId: 'project-B' });
|
||||
store.getState().openExtensionsTab();
|
||||
|
||||
const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
|
||||
const extTabAfter = tabsAfter.find((t) => t.type === 'extensions');
|
||||
expect(extTabAfter?.projectId).toBe('project-B');
|
||||
// Still only one extensions tab
|
||||
expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not update projectId when it already matches', () => {
|
||||
store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
|
||||
store.getState().openExtensionsTab();
|
||||
|
||||
const layoutBefore = store.getState().paneLayout;
|
||||
|
||||
// Reopen with same project — layout should be referentially stable (no set() call)
|
||||
store.getState().openExtensionsTab();
|
||||
|
||||
const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs);
|
||||
const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
|
||||
const extBefore = tabsBefore.find((t) => t.type === 'extensions');
|
||||
const extAfter = tabsAfter.find((t) => t.type === 'extensions');
|
||||
expect(extAfter?.projectId).toBe(extBefore?.projectId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installPlugin', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||
import { resolveProjectIdByPath, resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
|
||||
import type { Project, RepositoryGroup } from '@renderer/types/data';
|
||||
|
||||
|
|
@ -9,16 +9,19 @@ import type { Project, RepositoryGroup } from '@renderer/types/data';
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProjectLike = Pick<Project, 'id' | 'path'>;
|
||||
type ProjectWithName = Pick<Project, 'id' | 'path' | 'name'>;
|
||||
type RepoGroupLike = Pick<RepositoryGroup, 'worktrees'>;
|
||||
|
||||
const CRYPTO_PROJECT: ProjectLike = {
|
||||
const CRYPTO_PROJECT: ProjectWithName = {
|
||||
id: '-Users-belief-dev-projects-crypto-research',
|
||||
path: '/Users/belief/dev/projects/crypto_research',
|
||||
name: 'crypto_research',
|
||||
};
|
||||
|
||||
const CLAUDE_PROJECT: ProjectLike = {
|
||||
const CLAUDE_PROJECT: ProjectWithName = {
|
||||
id: '-Users-belief-dev-projects-claude-claude-team',
|
||||
path: '/Users/belief/dev/projects/claude/claude_team',
|
||||
name: 'claude_team',
|
||||
};
|
||||
|
||||
function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike {
|
||||
|
|
@ -290,3 +293,156 @@ describe('resolveProjectIdByPath', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// resolveProjectPathById — inverse lookup (ID → path + name)
|
||||
// ===========================================================================
|
||||
|
||||
describe('resolveProjectPathById', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Null / undefined / empty input
|
||||
// -----------------------------------------------------------------------
|
||||
describe('null/undefined/empty projectId', () => {
|
||||
it('returns null for undefined projectId', () => {
|
||||
expect(resolveProjectPathById(undefined, [CRYPTO_PROJECT], [])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null projectId', () => {
|
||||
expect(resolveProjectPathById(null, [CRYPTO_PROJECT], [])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string projectId', () => {
|
||||
expect(resolveProjectPathById('', [CRYPTO_PROJECT], [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lookup from projects (flat view mode)
|
||||
// -----------------------------------------------------------------------
|
||||
describe('lookup from projects (flat mode)', () => {
|
||||
it('finds project by exact id match', () => {
|
||||
const result = resolveProjectPathById(
|
||||
'-Users-belief-dev-projects-crypto-research',
|
||||
[CRYPTO_PROJECT, CLAUDE_PROJECT],
|
||||
[]
|
||||
);
|
||||
expect(result).toEqual({
|
||||
path: '/Users/belief/dev/projects/crypto_research',
|
||||
name: 'crypto_research',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when id not in projects', () => {
|
||||
expect(
|
||||
resolveProjectPathById('-Users-belief-dev-projects-unknown', [CRYPTO_PROJECT], [])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when projects list is empty', () => {
|
||||
expect(
|
||||
resolveProjectPathById('-Users-belief-dev-projects-crypto-research', [], [])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lookup from repositoryGroups (grouped view mode)
|
||||
// -----------------------------------------------------------------------
|
||||
describe('lookup from repositoryGroups (grouped mode)', () => {
|
||||
it('finds project in worktrees when projects is empty', () => {
|
||||
const result = resolveProjectPathById(
|
||||
'-Users-belief-dev-projects-crypto-research',
|
||||
[],
|
||||
[CRYPTO_REPO_GROUP]
|
||||
);
|
||||
expect(result).toEqual({
|
||||
path: '/Users/belief/dev/projects/crypto_research',
|
||||
name: '-Users-belief-dev-projects-crypto-research',
|
||||
});
|
||||
});
|
||||
|
||||
it('finds project across multiple repo groups', () => {
|
||||
const result = resolveProjectPathById(
|
||||
'-Users-belief-dev-projects-claude-claude-team',
|
||||
[],
|
||||
[CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP]
|
||||
);
|
||||
expect(result).toEqual({
|
||||
path: '/Users/belief/dev/projects/claude/claude_team',
|
||||
name: '-Users-belief-dev-projects-claude-claude-team',
|
||||
});
|
||||
});
|
||||
|
||||
it('finds correct worktree in multi-worktree group', () => {
|
||||
const result = resolveProjectPathById(
|
||||
'-Users-belief-dev-projects-app-wt-feature',
|
||||
[],
|
||||
[MULTI_WORKTREE_GROUP]
|
||||
);
|
||||
expect(result).toEqual({
|
||||
path: '/Users/belief/dev/projects/app-wt-feature',
|
||||
name: '-Users-belief-dev-projects-app-wt-feature',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when id not in any worktree', () => {
|
||||
expect(
|
||||
resolveProjectPathById('-Users-belief-dev-projects-unknown', [], [CRYPTO_REPO_GROUP])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Priority: projects takes precedence over repositoryGroups
|
||||
// -----------------------------------------------------------------------
|
||||
describe('priority order', () => {
|
||||
it('prefers projects match over repositoryGroups match', () => {
|
||||
const projectEntry: ProjectWithName = {
|
||||
id: 'shared-id',
|
||||
path: '/from/projects',
|
||||
name: 'from-projects',
|
||||
};
|
||||
|
||||
const repoGroupEntry = makeRepoGroup([
|
||||
{ id: 'shared-id', path: '/from/repo-group' },
|
||||
]);
|
||||
|
||||
const result = resolveProjectPathById('shared-id', [projectEntry], [repoGroupEntry]);
|
||||
expect(result).toEqual({ path: '/from/projects', name: 'from-projects' });
|
||||
});
|
||||
|
||||
it('falls back to repositoryGroups when projects has no match', () => {
|
||||
const result = resolveProjectPathById(
|
||||
'-Users-belief-dev-projects-crypto-research',
|
||||
[CLAUDE_PROJECT],
|
||||
[CRYPTO_REPO_GROUP]
|
||||
);
|
||||
expect(result).toEqual({
|
||||
path: '/Users/belief/dev/projects/crypto_research',
|
||||
name: '-Users-belief-dev-projects-crypto-research',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression: Extensions tab with grouped view mode
|
||||
// -----------------------------------------------------------------------
|
||||
describe('regression: Extensions tab skills in grouped view mode', () => {
|
||||
it('resolves projectPath from id when only repositoryGroups is populated', () => {
|
||||
// This is the exact scenario that caused skills not to show:
|
||||
// viewMode=grouped → projects=[] but repositoryGroups has the data
|
||||
// ExtensionStoreView used projects.find(p => p.id === tabProjectId)
|
||||
// which returned null, so projectPath was null and no project skills loaded
|
||||
const emptyProjects: ProjectWithName[] = [];
|
||||
const populatedGroups: RepoGroupLike[] = [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP];
|
||||
|
||||
const result = resolveProjectPathById(
|
||||
'-Users-belief-dev-projects-crypto-research',
|
||||
emptyProjects,
|
||||
populatedGroups
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.path).toBe('/Users/belief/dev/projects/crypto_research');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
27
test/renderer/utils/providerSlashCommands.test.ts
Normal file
27
test/renderer/utils/providerSlashCommands.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
|
||||
|
||||
describe('getSuggestedSlashCommandsForProvider', () => {
|
||||
it('returns Codex-specific command suggestions without Anthropic-only entries', () => {
|
||||
const commands = getSuggestedSlashCommandsForProvider('codex').map(
|
||||
(command) => command.command
|
||||
);
|
||||
|
||||
expect(commands).toContain('/permissions');
|
||||
expect(commands).toContain('/agent');
|
||||
expect(commands).toContain('/review');
|
||||
expect(commands).not.toContain('/effort');
|
||||
expect(commands).not.toContain('/usage');
|
||||
});
|
||||
|
||||
it('falls back to the default curated list for Anthropic-like providers', () => {
|
||||
const commands = getSuggestedSlashCommandsForProvider('anthropic').map(
|
||||
(command) => command.command
|
||||
);
|
||||
|
||||
expect(commands).toContain('/effort');
|
||||
expect(commands).toContain('/usage');
|
||||
expect(commands).not.toContain('/permissions');
|
||||
});
|
||||
});
|
||||
94
test/renderer/utils/skillCommandSuggestions.test.ts
Normal file
94
test/renderer/utils/skillCommandSuggestions.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
|
||||
import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands';
|
||||
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
function createSkill(overrides: Partial<SkillCatalogItem>): SkillCatalogItem {
|
||||
return {
|
||||
id: overrides.id ?? 'skill-id',
|
||||
sourceType: 'filesystem',
|
||||
name: overrides.name ?? 'Skill Name',
|
||||
description: overrides.description ?? 'Skill description',
|
||||
folderName: overrides.folderName ?? 'skill-name',
|
||||
scope: overrides.scope ?? 'project',
|
||||
rootKind: overrides.rootKind ?? 'claude',
|
||||
projectRoot: overrides.projectRoot ?? '/Users/test/project',
|
||||
discoveryRoot: overrides.discoveryRoot ?? '/Users/test/project/.claude/skills',
|
||||
skillDir: overrides.skillDir ?? '/Users/test/project/.claude/skills/skill-name',
|
||||
skillFile: overrides.skillFile ?? '/Users/test/project/.claude/skills/skill-name/SKILL.md',
|
||||
metadata: overrides.metadata ?? {},
|
||||
invocationMode: overrides.invocationMode ?? 'manual-only',
|
||||
flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false },
|
||||
isValid: overrides.isValid ?? true,
|
||||
issues: overrides.issues ?? [],
|
||||
modifiedAt: overrides.modifiedAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildSlashCommandSuggestions', () => {
|
||||
it('keeps built-ins and adds valid skills in a separate suggestion type', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
[createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' })],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(suggestions[0]?.type).toBe('command');
|
||||
expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true);
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject(
|
||||
{
|
||||
name: 'review-skill',
|
||||
command: '/review-skill',
|
||||
subtitle: 'Project skill',
|
||||
type: 'skill',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('filters slash-unsafe names and built-in collisions', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
[
|
||||
createSkill({ id: 'unsafe', folderName: 'bad skill' }),
|
||||
createSkill({ id: 'collision', folderName: 'plan' }),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:unsafe')).toBeUndefined();
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers project skills when user and project skills share the same slash name', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
[createSkill({ id: 'project', folderName: 'shared-skill', scope: 'project' })],
|
||||
[createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })]
|
||||
);
|
||||
|
||||
expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe(
|
||||
'skill:project'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the provided built-in set when filtering skill collisions', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(
|
||||
[
|
||||
{
|
||||
name: 'custom-cmd',
|
||||
command: '/custom-cmd',
|
||||
description: 'Custom command',
|
||||
},
|
||||
],
|
||||
[createSkill({ id: 'collision', folderName: 'custom-cmd' })],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
getKnownSlashCommand,
|
||||
isSupportedSlashCommandName,
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
|
|
@ -53,4 +54,11 @@ describe('slashCommands', () => {
|
|||
expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model');
|
||||
expect(getKnownSlashCommand('foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates slash-compatible command names', () => {
|
||||
expect(isSupportedSlashCommandName('review')).toBe(true);
|
||||
expect(isSupportedSlashCommandName('skill:name')).toBe(true);
|
||||
expect(isSupportedSlashCommandName('my_skill')).toBe(false);
|
||||
expect(isSupportedSlashCommandName('my skill')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue