This commit is contained in:
777genius 2026-04-14 22:07:15 +03:00
commit e5d85ec873
29 changed files with 1059 additions and 105 deletions

View file

@ -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>

View file

@ -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;

View file

@ -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,

View file

@ -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);

View file

@ -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(
() => [
{

View file

@ -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'

View file

@ -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]';

View file

@ -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,
};
}

View file

@ -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();

View file

@ -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,
},
};
}

View file

@ -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>

View file

@ -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,
});
},

View file

@ -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',

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View 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;
}

View 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];
}

View 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);
}

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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]');
});
});

View file

@ -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,
});
});
});

View file

@ -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');

View file

@ -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', () => {

View file

@ -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');
});
});
});

View 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');
});
});

View 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();
});
});

View file

@ -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);
});
});