Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path';
import { eq, sql } from 'drizzle-orm';
import { and, eq, isNull, ne, sql } from 'drizzle-orm';
import { projectManager } from '@main/core/projects/project-manager';
import type { ProjectProvider, TaskProvider } from '@main/core/projects/project-provider';
import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager';
Expand Down Expand Up @@ -233,13 +233,24 @@ export class WorkspaceBootstrapService {

const resolvedPath = setupResult.data.path;
if (resolvedPath) {
await this.persistPath(
const persistedWorkspaceId = await this.persistPath(
workspaceRow.id,
resolvedPath,
workspaceRow.type,
connectionId,
intentBranchName
);
if (persistedWorkspaceId !== workspaceRow.id) {
const owningTask = await this.findOwningTask(persistedWorkspaceId, task.projectId, task.id);
if (owningTask) {
return err({
type: 'workspace-already-checked-out',
branchName: intentBranchName,
taskId: owningTask.id,
taskName: owningTask.name,
});
}
}
}

if (connectionId) {
Expand Down Expand Up @@ -281,6 +292,27 @@ export class WorkspaceBootstrapService {
return this.ensureWorkspaceSetup(wsRow, row, task, project);
}

private async findOwningTask(
workspaceId: string,
projectId: string,
ignoredTaskId: string
): Promise<{ id: string; name: string } | null> {
const rows = await this.db
.select({ id: tasks.id, name: tasks.name })
.from(tasks)
.where(
and(
eq(tasks.workspaceId, workspaceId),
eq(tasks.projectId, projectId),
isNull(tasks.archivedAt),
ne(tasks.id, ignoredTaskId)
)
)
.limit(1);

return rows[0] ?? null;
}

/**
* Persists a resolved path (and its derived key) onto a workspace row.
*
Expand Down
21 changes: 20 additions & 1 deletion apps/emdash-desktop/src/renderer/features/tasks/main-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react';
import { ExternalLink, Loader2 } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import { useEffect } from 'react';
import { usePanelRef } from 'react-resizable-panels';
Expand All @@ -11,7 +11,9 @@ import {
useTaskViewContext,
useWorkspaceViewModel,
} from '@renderer/features/tasks/task-view-context';
import { useNavigate } from '@renderer/lib/layout/navigation-provider';
import { panelDragStore } from '@renderer/lib/layout/panel-drag-store';
import { Button } from '@renderer/lib/ui/button';
import { ResizablePanel, ResizablePanelGroup } from '@renderer/lib/ui/resizable';
import { DraggableResizeHandle, TaskMainColumn } from './view/task-main-column';
import { TaskSidebar } from './view/task-sidebar';
Expand All @@ -20,6 +22,7 @@ export const TaskMainPanel = observer(function TaskMainPanel() {
const { projectId, taskId } = useTaskViewContext();
const taskStore = getTaskStore(projectId, taskId);
const kind = taskViewKind(taskStore, projectId);
const { navigate } = useNavigate();

if (kind === 'creating') {
return (
Expand Down Expand Up @@ -54,13 +57,29 @@ export const TaskMainPanel = observer(function TaskMainPanel() {
}

if (kind === 'provision-error' || kind === 'project-error') {
const existingTask =
taskStore?.provisionError?.type === 'workspace-already-checked-out'
? taskStore.provisionError
: null;

return (
<div className="flex h-full w-full flex-col items-center justify-center p-8">
<div className="flex max-w-xs flex-col items-center gap-2 text-center">
<p className="font-mono text-sm font-medium text-foreground-destructive">
Failed to set up workspace
</p>
<p className="font-mono text-xs text-foreground-muted">{taskErrorMessage(taskStore)}</p>
{existingTask && (
<Button
variant="outline"
size="sm"
className="mt-2 gap-1.5"
onClick={() => navigate('task', { projectId, taskId: existingTask.taskId })}
>
<ExternalLink className="size-3.5" />
Open existing task
</Button>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ function formatProvisionWorkspaceError(error: ProvisionWorkspaceError): string {
return 'Workspace has no intent and no resolved path — cannot provision.';
case 'setup-failed':
return `Setup step '${error.stepKind}' failed (${error.stepErrorType})${error.message ? `: ${error.message}` : ''}.`;
case 'workspace-already-checked-out':
return error.branchName
? `Branch "${error.branchName}" is already checked out by task "${error.taskName}".`
: `This workspace is already checked out by task "${error.taskName}".`;
}
}

Expand Down Expand Up @@ -451,6 +455,7 @@ export class TaskManagerStore {
if (current && isUnprovisioned(current)) {
current.phase = 'provision-error';
current.errorMessage = message;
current.provisionError = result.error;
}
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { rpc } from '@renderer/lib/ipc';
import { log } from '@renderer/utils/logger';
import type { LinkedIssue } from '@shared/core/linked-issue';
import type {
ProvisionWorkspaceError,
RenameTaskError,
RenameTaskSuccess,
Task,
Expand Down Expand Up @@ -41,6 +42,7 @@ export class TaskStore {
data: UnregisteredTaskData | Task;
phase: UnregisteredTaskPhase | UnprovisionedTaskPhase | null;
errorMessage: string | undefined = undefined;
provisionError: ProvisionWorkspaceError | undefined = undefined;
provisionProgressMessage: string | null = null;

/** The workspace ID for this task session — null when unprovisioned. */
Expand Down Expand Up @@ -119,6 +121,7 @@ export class TaskStore {
this.state = 'provisioned';
this.phase = null;
this.errorMessage = undefined;
this.provisionError = undefined;
this.provisionProgressMessage = null;
this.viewModel?.initialize();
}
Expand All @@ -133,6 +136,7 @@ export class TaskStore {
this.state = 'unprovisioned';
this.phase = phase;
this.errorMessage = undefined;
this.provisionError = undefined;
this.provisionProgressMessage = null;

// Create stable stores on first registration (when transitioning from unregistered).
Expand All @@ -145,6 +149,7 @@ export class TaskStore {
this.state = 'unprovisioned';
this.phase = phase;
this.errorMessage = undefined;
this.provisionError = undefined;
this.provisionProgressMessage = null;
}

Expand Down
8 changes: 7 additions & 1 deletion apps/emdash-desktop/src/shared/core/tasks/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ export type ProvisionTaskResult = {

export type ProvisionWorkspaceError =
| { type: 'no-intent' }
| { type: 'setup-failed'; stepKind: string; stepErrorType: string; message?: string };
| { type: 'setup-failed'; stepKind: string; stepErrorType: string; message?: string }
| {
type: 'workspace-already-checked-out';
branchName?: string;
taskId: string;
taskName: string;
};

export type DeleteTaskOptions = {
deleteWorktree?: boolean;
Expand Down
Loading