- Introduced a comprehensive implementation plan for the diff view feature, structured in four phases: MVP (read-only), accept/reject per hunk, per-task scoping, and enhanced features. - Phase 1 includes a read-only diff view per agent, utilizing JSONL data to display file changes. - Defined new types for file changes and review data, and established IPC channels for fetching member changes and reading file content. - Developed backend services for extracting file changes and aggregating review data, alongside frontend components for displaying diffs and managing state. - Subsequent phases will enhance the diff view with accept/reject functionality, task-specific change scoping, and improved user experience features.
620 lines
20 KiB
TypeScript
620 lines
20 KiB
TypeScript
import { api } from '@renderer/api';
|
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
|
|
|
const logger = createLogger('teamSlice');
|
|
|
|
import type { AppState } from '../types';
|
|
import type {
|
|
AddMemberRequest,
|
|
CreateTaskRequest,
|
|
GlobalTask,
|
|
KanbanColumnId,
|
|
SendMessageRequest,
|
|
SendMessageResult,
|
|
TaskComment,
|
|
TeamCreateRequest,
|
|
TeamData,
|
|
TeamLaunchRequest,
|
|
TeamProvisioningProgress,
|
|
TeamSummary,
|
|
TeamTask,
|
|
TeamTaskStatus,
|
|
UpdateKanbanPatch,
|
|
} from '@shared/types';
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
function mapSendMessageError(error: unknown): string {
|
|
const message =
|
|
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
|
if (message.includes('Failed to verify inbox write')) {
|
|
return 'Message was written but not verified (race). Please try again.';
|
|
}
|
|
return message || 'Failed to send message';
|
|
}
|
|
|
|
function mapReviewError(error: unknown): string {
|
|
const message =
|
|
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
|
if (message.includes('Task status update verification failed')) {
|
|
return 'Failed to update task status (possible agent conflict).';
|
|
}
|
|
return message || 'Failed to perform review action';
|
|
}
|
|
|
|
export interface TeamSlice {
|
|
teams: TeamSummary[];
|
|
teamsLoading: boolean;
|
|
teamsError: string | null;
|
|
globalTasks: GlobalTask[];
|
|
globalTasksLoading: boolean;
|
|
globalTasksError: string | null;
|
|
selectedTeamName: string | null;
|
|
selectedTeamData: TeamData | null;
|
|
selectedTeamLoading: boolean;
|
|
selectedTeamError: string | null;
|
|
sendingMessage: boolean;
|
|
sendMessageError: string | null;
|
|
lastSendMessageResult: SendMessageResult | null;
|
|
reviewActionError: string | null;
|
|
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
|
activeProvisioningRunId: string | null;
|
|
provisioningError: string | null;
|
|
kanbanFilterQuery: string | null;
|
|
provisioningProgressUnsubscribe: (() => void) | null;
|
|
fetchTeams: () => Promise<void>;
|
|
fetchAllTasks: () => Promise<void>;
|
|
openTeamsTab: () => void;
|
|
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
|
|
clearKanbanFilter: () => void;
|
|
selectTeam: (teamName: string) => Promise<void>;
|
|
refreshTeamData: (teamName: string) => Promise<void>;
|
|
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
|
updateKanbanColumnOrder: (
|
|
teamName: string,
|
|
columnId: KanbanColumnId,
|
|
orderedTaskIds: string[]
|
|
) => Promise<void>;
|
|
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
|
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
|
|
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
|
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
|
|
addingComment: boolean;
|
|
addCommentError: string | null;
|
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
|
updateMemberRole: (
|
|
teamName: string,
|
|
memberName: string,
|
|
role: string | undefined
|
|
) => Promise<void>;
|
|
deleteTeam: (teamName: string) => Promise<void>;
|
|
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
|
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
|
cancelProvisioning: (runId: string) => Promise<void>;
|
|
getProvisioningStatus: (runId: string) => Promise<void>;
|
|
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
|
|
subscribeProvisioningProgress: () => void;
|
|
unsubscribeProvisioningProgress: () => void;
|
|
}
|
|
|
|
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
|
|
teams: [],
|
|
teamsLoading: false,
|
|
teamsError: null,
|
|
globalTasks: [],
|
|
globalTasksLoading: false,
|
|
globalTasksError: null,
|
|
selectedTeamName: null,
|
|
selectedTeamData: null,
|
|
selectedTeamLoading: false,
|
|
selectedTeamError: null,
|
|
sendingMessage: false,
|
|
sendMessageError: null,
|
|
lastSendMessageResult: null,
|
|
reviewActionError: null,
|
|
provisioningRuns: {},
|
|
activeProvisioningRunId: null,
|
|
provisioningError: null,
|
|
kanbanFilterQuery: null,
|
|
addingComment: false,
|
|
addCommentError: null,
|
|
provisioningProgressUnsubscribe: null,
|
|
|
|
fetchTeams: async () => {
|
|
set({ teamsLoading: true, teamsError: null });
|
|
try {
|
|
const teams = await unwrapIpc('team:list', () => api.teams.list());
|
|
set({ teams, teamsLoading: false, teamsError: null });
|
|
} catch (error) {
|
|
set({
|
|
teamsLoading: false,
|
|
teamsError:
|
|
error instanceof IpcError
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Failed to fetch teams',
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchAllTasks: async () => {
|
|
set({ globalTasksLoading: true, globalTasksError: null });
|
|
try {
|
|
const tasks = await unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks());
|
|
set({ globalTasks: tasks, globalTasksLoading: false, globalTasksError: null });
|
|
} catch (error) {
|
|
set({
|
|
globalTasksLoading: false,
|
|
globalTasksError:
|
|
error instanceof IpcError
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Failed to fetch tasks',
|
|
});
|
|
}
|
|
},
|
|
|
|
openTeamsTab: () => {
|
|
const state = get();
|
|
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
|
const teamsTab = focusedPane?.tabs.find((tab) => tab.type === 'teams');
|
|
if (teamsTab) {
|
|
state.setActiveTab(teamsTab.id);
|
|
return;
|
|
}
|
|
|
|
state.openTab({
|
|
type: 'teams',
|
|
label: 'Teams',
|
|
});
|
|
},
|
|
|
|
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => {
|
|
if (!teamName.trim()) {
|
|
return;
|
|
}
|
|
|
|
// If projectPath is provided, immediately select the matching project in the sidebar.
|
|
// This avoids a race condition where config.json hasn't been updated with projectPath yet.
|
|
if (projectPath) {
|
|
const stateForProject = get();
|
|
const normalizedPath = normalizePath(projectPath);
|
|
const matchingProject = stateForProject.projects.find(
|
|
(p) => normalizePath(p.path) === normalizedPath
|
|
);
|
|
if (matchingProject && stateForProject.selectedProjectId !== matchingProject.id) {
|
|
stateForProject.selectProject(matchingProject.id);
|
|
}
|
|
}
|
|
|
|
const state = get();
|
|
// Use display name from teams list or selected team data if available
|
|
const teamSummary = state.teams.find((t) => t.teamName === teamName);
|
|
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
|
|
|
|
const allTabs = state.getAllPaneTabs();
|
|
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
|
if (existing) {
|
|
state.setActiveTab(existing.id);
|
|
// Sync label in case display name changed
|
|
if (existing.label !== displayName) {
|
|
state.updateTabLabel(existing.id, displayName);
|
|
}
|
|
} else {
|
|
state.openTab({
|
|
type: 'team',
|
|
label: displayName,
|
|
teamName,
|
|
});
|
|
}
|
|
|
|
if (taskId) {
|
|
set({ kanbanFilterQuery: `#${taskId}` });
|
|
}
|
|
},
|
|
|
|
clearKanbanFilter: () => {
|
|
set({ kanbanFilterQuery: null });
|
|
},
|
|
|
|
selectTeam: async (teamName: string) => {
|
|
// Clear stale data immediately to prevent flash of previous team's content
|
|
const prev = get().selectedTeamName;
|
|
set({
|
|
selectedTeamName: teamName,
|
|
selectedTeamData: prev !== teamName ? null : get().selectedTeamData,
|
|
selectedTeamLoading: true,
|
|
selectedTeamError: null,
|
|
reviewActionError: null,
|
|
});
|
|
|
|
// If this team is being provisioned right now, config.json doesn't exist yet.
|
|
// Stay in loading state — the provisioning progress callback will re-call
|
|
// selectTeam once config is written.
|
|
const isProvisioningNow = Object.values(get().provisioningRuns).some(
|
|
(run) =>
|
|
run.teamName === teamName &&
|
|
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
|
|
);
|
|
if (isProvisioningNow) {
|
|
set({
|
|
selectedTeamLoading: true,
|
|
selectedTeamData: null,
|
|
selectedTeamError: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName));
|
|
// Stale check: user may have switched to another team during the async call
|
|
if (get().selectedTeamName !== teamName) {
|
|
return;
|
|
}
|
|
set({
|
|
selectedTeamName: teamName,
|
|
selectedTeamData: data,
|
|
selectedTeamLoading: false,
|
|
selectedTeamError: null,
|
|
});
|
|
|
|
// Sync tab label with the team's display name from config
|
|
const displayName = data.config.name || teamName;
|
|
const allTabs = get().getAllPaneTabs();
|
|
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
|
if (teamTab && teamTab.label !== displayName) {
|
|
get().updateTabLabel(teamTab.id, displayName);
|
|
}
|
|
|
|
// Auto-select the project associated with this team's cwd/projectPath.
|
|
// Must search both flat projects and grouped repositoryGroups/worktrees
|
|
// because the default viewMode is 'grouped' and flat projects may be empty.
|
|
const projectPath = data.config.projectPath;
|
|
if (projectPath) {
|
|
const state = get();
|
|
const normalizedTeamPath = normalizePath(projectPath);
|
|
|
|
// 1. Try flat projects list
|
|
const matchingProject = state.projects.find(
|
|
(p) => normalizePath(p.path) === normalizedTeamPath
|
|
);
|
|
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
|
|
state.selectProject(matchingProject.id);
|
|
} else if (!matchingProject) {
|
|
// 2. Try grouped view: search worktrees across all repository groups
|
|
for (const repo of state.repositoryGroups) {
|
|
const matchingWorktree = repo.worktrees.find(
|
|
(wt) => normalizePath(wt.path) === normalizedTeamPath
|
|
);
|
|
if (matchingWorktree) {
|
|
if (state.selectedWorktreeId !== matchingWorktree.id) {
|
|
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
|
|
void get().fetchSessionsInitial(matchingWorktree.id);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// If provisioning is in progress for this team, stay in loading state;
|
|
// file watcher / progress callback will refresh once config is written.
|
|
const isProvisioning = Object.values(get().provisioningRuns).some(
|
|
(run) =>
|
|
run.teamName === teamName &&
|
|
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
|
|
);
|
|
|
|
if (isProvisioning) {
|
|
set({
|
|
selectedTeamLoading: true,
|
|
selectedTeamData: null,
|
|
selectedTeamError: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const message =
|
|
error instanceof IpcError
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Failed to fetch team data';
|
|
logger.error(`[team:getData] ${message}`);
|
|
set({
|
|
selectedTeamLoading: false,
|
|
selectedTeamData: null,
|
|
selectedTeamError: message,
|
|
});
|
|
}
|
|
},
|
|
|
|
refreshTeamData: async (teamName: string) => {
|
|
const state = get();
|
|
if (state.selectedTeamName !== teamName) {
|
|
return;
|
|
}
|
|
// Silent refresh — update data without showing loading skeleton.
|
|
// Only selectTeam() sets loading: true (for initial load).
|
|
try {
|
|
const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName));
|
|
// Re-check after async: the user might have navigated away.
|
|
if (get().selectedTeamName !== teamName) {
|
|
return;
|
|
}
|
|
set({
|
|
selectedTeamData: data,
|
|
selectedTeamError: null,
|
|
});
|
|
} catch (error) {
|
|
if (get().selectedTeamName !== teamName) {
|
|
return;
|
|
}
|
|
set({
|
|
selectedTeamError:
|
|
error instanceof IpcError
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Failed to refresh team data',
|
|
});
|
|
}
|
|
},
|
|
|
|
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
|
|
try {
|
|
set({ reviewActionError: null });
|
|
await unwrapIpc('team:updateKanban', () => api.teams.updateKanban(teamName, taskId, patch));
|
|
await get().refreshTeamData(teamName);
|
|
} catch (error) {
|
|
set({
|
|
reviewActionError: mapReviewError(error),
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateKanbanColumnOrder: async (
|
|
teamName: string,
|
|
columnId: KanbanColumnId,
|
|
orderedTaskIds: string[]
|
|
) => {
|
|
await unwrapIpc('team:updateKanbanColumnOrder', () =>
|
|
api.teams.updateKanbanColumnOrder(teamName, columnId, orderedTaskIds)
|
|
);
|
|
await get().refreshTeamData(teamName);
|
|
},
|
|
|
|
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
|
|
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
|
|
try {
|
|
const result = await unwrapIpc('team:sendMessage', () =>
|
|
api.teams.sendMessage(teamName, request)
|
|
);
|
|
set({
|
|
sendingMessage: false,
|
|
sendMessageError: null,
|
|
lastSendMessageResult: result,
|
|
});
|
|
await get().refreshTeamData(teamName);
|
|
} catch (error) {
|
|
set({
|
|
sendingMessage: false,
|
|
lastSendMessageResult: null,
|
|
sendMessageError: mapSendMessageError(error),
|
|
});
|
|
}
|
|
},
|
|
|
|
requestReview: async (teamName: string, taskId: string) => {
|
|
try {
|
|
set({ reviewActionError: null });
|
|
await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId));
|
|
await get().refreshTeamData(teamName);
|
|
} catch (error) {
|
|
set({
|
|
reviewActionError: mapReviewError(error),
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
createTeamTask: async (teamName: string, request: CreateTaskRequest) => {
|
|
const task = await unwrapIpc('team:createTask', () => api.teams.createTask(teamName, request));
|
|
await get().refreshTeamData(teamName);
|
|
return task;
|
|
},
|
|
|
|
startTask: async (teamName: string, taskId: string) => {
|
|
const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
|
|
await get().refreshTeamData(teamName);
|
|
return result;
|
|
},
|
|
|
|
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
|
await unwrapIpc('team:updateTaskStatus', () =>
|
|
api.teams.updateTaskStatus(teamName, taskId, status)
|
|
);
|
|
await get().refreshTeamData(teamName);
|
|
},
|
|
|
|
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
|
|
await unwrapIpc('team:updateTaskOwner', () =>
|
|
api.teams.updateTaskOwner(teamName, taskId, owner)
|
|
);
|
|
await get().refreshTeamData(teamName);
|
|
},
|
|
|
|
addTaskComment: async (teamName, taskId, text) => {
|
|
set({ addingComment: true, addCommentError: null });
|
|
try {
|
|
const comment = await unwrapIpc('team:addTaskComment', () =>
|
|
api.teams.addTaskComment(teamName, taskId, text)
|
|
);
|
|
set({ addingComment: false });
|
|
await get().refreshTeamData(teamName);
|
|
return comment;
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : 'Failed to add comment';
|
|
set({ addingComment: false, addCommentError: msg });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
addMember: async (teamName: string, request: AddMemberRequest) => {
|
|
await unwrapIpc('team:addMember', () => api.teams.addMember(teamName, request));
|
|
await get().refreshTeamData(teamName);
|
|
},
|
|
|
|
removeMember: async (teamName: string, memberName: string) => {
|
|
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
|
await get().refreshTeamData(teamName);
|
|
},
|
|
|
|
updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => {
|
|
await unwrapIpc('team:updateMemberRole', () =>
|
|
api.teams.updateMemberRole(teamName, memberName, role)
|
|
);
|
|
await get().refreshTeamData(teamName);
|
|
},
|
|
|
|
deleteTeam: async (teamName: string) => {
|
|
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
|
const state = get();
|
|
if (state.selectedTeamName === teamName) {
|
|
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null });
|
|
}
|
|
await get().fetchTeams();
|
|
},
|
|
|
|
createTeam: async (request: TeamCreateRequest) => {
|
|
// Clear stale provisioning runs for this team so the banner starts fresh
|
|
set((state) => {
|
|
const cleaned = { ...state.provisioningRuns };
|
|
for (const [runId, run] of Object.entries(cleaned)) {
|
|
if (run.teamName === request.teamName) {
|
|
delete cleaned[runId];
|
|
}
|
|
}
|
|
return { provisioningError: null, provisioningRuns: cleaned };
|
|
});
|
|
try {
|
|
if (typeof api.teams.createTeam !== 'function') {
|
|
throw new Error(
|
|
'Current preload version does not support team:create. Restart the dev app.'
|
|
);
|
|
}
|
|
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
|
|
set({
|
|
activeProvisioningRunId: response.runId,
|
|
provisioningError: null,
|
|
});
|
|
await get().getProvisioningStatus(response.runId);
|
|
return response.runId;
|
|
} catch (error) {
|
|
set({
|
|
provisioningError:
|
|
error instanceof IpcError
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Failed to create team',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
launchTeam: async (request: TeamLaunchRequest) => {
|
|
// Clear stale provisioning runs for this team so the banner starts fresh
|
|
set((state) => {
|
|
const cleaned = { ...state.provisioningRuns };
|
|
for (const [runId, run] of Object.entries(cleaned)) {
|
|
if (run.teamName === request.teamName) {
|
|
delete cleaned[runId];
|
|
}
|
|
}
|
|
return { provisioningError: null, provisioningRuns: cleaned };
|
|
});
|
|
try {
|
|
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
|
set({
|
|
activeProvisioningRunId: response.runId,
|
|
provisioningError: null,
|
|
});
|
|
await get().getProvisioningStatus(response.runId);
|
|
return response.runId;
|
|
} catch (error) {
|
|
set({
|
|
provisioningError:
|
|
error instanceof IpcError
|
|
? error.message
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Failed to launch team',
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getProvisioningStatus: async (runId: string) => {
|
|
const progress = await unwrapIpc('team:provisioningStatus', () =>
|
|
api.teams.getProvisioningStatus(runId)
|
|
);
|
|
get().onProvisioningProgress(progress);
|
|
},
|
|
|
|
cancelProvisioning: async (runId: string) => {
|
|
await unwrapIpc('team:cancelProvisioning', () => api.teams.cancelProvisioning(runId));
|
|
},
|
|
|
|
onProvisioningProgress: (progress: TeamProvisioningProgress) => {
|
|
set((state) => ({
|
|
provisioningRuns: {
|
|
...state.provisioningRuns,
|
|
[progress.runId]: progress,
|
|
},
|
|
activeProvisioningRunId: progress.runId,
|
|
provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null,
|
|
}));
|
|
|
|
if (progress.state === 'ready' || progress.state === 'disconnected') {
|
|
void get().fetchTeams();
|
|
// If the user already opened the team tab, reload team data now that
|
|
// config.json is guaranteed to exist.
|
|
if (get().selectedTeamName === progress.teamName) {
|
|
void get().selectTeam(progress.teamName);
|
|
}
|
|
}
|
|
},
|
|
|
|
subscribeProvisioningProgress: () => {
|
|
const existing = get().provisioningProgressUnsubscribe;
|
|
if (existing) {
|
|
return;
|
|
}
|
|
if (!api.teams?.onProvisioningProgress) {
|
|
return;
|
|
}
|
|
const unsubscribe = api.teams.onProvisioningProgress((_event, progress) => {
|
|
get().onProvisioningProgress(progress);
|
|
});
|
|
set({ provisioningProgressUnsubscribe: unsubscribe });
|
|
},
|
|
|
|
unsubscribeProvisioningProgress: () => {
|
|
const unsubscribe = get().provisioningProgressUnsubscribe;
|
|
if (unsubscribe) {
|
|
unsubscribe();
|
|
set({ provisioningProgressUnsubscribe: null });
|
|
}
|
|
},
|
|
});
|