Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 1bf5b5c

Browse files
authored
feat: cloud pr flow (#908)
1 parent 9ea61b1 commit 1bf5b5c

4 files changed

Lines changed: 133 additions & 6 deletions

File tree

‎apps/twig/src/renderer/features/sidebar/components/items/TaskItem.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Tooltip } from "@components/ui/Tooltip";
33
import {
44
ArrowsClockwise,
55
BellRinging,
6+
Cloud as CloudIcon,
67
GitBranch as GitBranchIcon,
78
Laptop as LaptopIcon,
89
PushPin,
@@ -139,6 +140,12 @@ export function TaskItem({
139140
</span>
140141
</Tooltip>
141142
)
143+
) : workspaceMode === "cloud" ? (
144+
<Tooltip content="Cloud" side="right">
145+
<span className="flex items-center justify-center">
146+
<CloudIcon size={ICON_SIZE} />
147+
</span>
148+
</Tooltip>
142149
) : (
143150
<Tooltip content="Local" side="right">
144151
<span className="flex items-center justify-center">

‎apps/twig/src/renderer/features/task-detail/components/TaskLogsPanel.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
7272
if (isConnecting.current) return;
7373
if (!isOnline) return;
7474

75+
// Cloud tasks are handled by the sandbox
76+
if (workspace?.mode === "cloud") return;
77+
7578
if (
7679
session?.status === "connected" ||
7780
session?.status === "connecting" ||
@@ -106,7 +109,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
106109
.finally(() => {
107110
isConnecting.current = false;
108111
});
109-
}, [task, repoPath, session, markActivity, isOnline]);
112+
}, [task, repoPath, session, markActivity, isOnline, workspace?.mode]);
110113

111114
const handleSendPrompt = useCallback(
112115
async (text: string) => {
@@ -201,7 +204,9 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
201204
async (command: string) => {
202205
if (!repoPath) return;
203206

204-
const execId = `user-shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
207+
const execId = `user-shell-${Date.now()}-${Math.random()
208+
.toString(36)
209+
.slice(2, 9)}`;
205210
await getSessionService().startUserShellExecute(
206211
taskId,
207212
execId,

‎apps/twig/src/renderer/sagas/task/task-creation.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,14 @@ export class TaskCreationSaga extends Saga<
173173
}
174174

175175
// Step 6: Connect to session
176+
// Cloud create: skip local session — the sandbox handles execution
176177
const agentCwd =
177178
workspace?.worktreePath ?? workspace?.folderPath ?? repoPath;
179+
const isCloudCreate = !input.taskId && workspaceMode === "cloud";
178180
const shouldConnect =
179-
!!input.taskId || // Open: always connect to load chat history
180-
workspaceMode === "cloud" || // Cloud create: always connect
181-
!!agentCwd; // Local create: always connect if we have a cwd
181+
!isCloudCreate &&
182+
(!!input.taskId || // Open: always connect to load chat history
183+
!!agentCwd); // Local create: always connect if we have a cwd
182184

183185
if (shouldConnect) {
184186
const initialPrompt =

‎packages/agent/src/server/agent-server.ts‎

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,11 @@ export class AgentServer {
518518
await clientConnection.newSession({
519519
cwd: this.config.repositoryPath,
520520
mcpServers: [],
521-
_meta: { sessionId: payload.run_id },
521+
_meta: {
522+
sessionId: payload.run_id,
523+
taskRunId: payload.run_id,
524+
systemPrompt: { append: this.buildCloudSystemPrompt() },
525+
},
522526
});
523527

524528
this.session = {
@@ -533,6 +537,15 @@ export class AgentServer {
533537

534538
this.logger.info("Session initialized successfully");
535539

540+
// Signal in_progress so the UI can start polling for updates
541+
this.posthogAPI
542+
.updateTaskRun(payload.task_id, payload.run_id, {
543+
status: "in_progress",
544+
})
545+
.catch((err) =>
546+
this.logger.warn("Failed to set task run to in_progress", err),
547+
);
548+
536549
await this.sendInitialTaskMessage(payload);
537550
}
538551

@@ -579,6 +592,20 @@ export class AgentServer {
579592
}
580593
}
581594

595+
private buildCloudSystemPrompt(): string {
596+
return `
597+
# Cloud Task Execution
598+
599+
After completing the requested changes:
600+
1. Create a new branch with a descriptive name based on the work done
601+
2. Stage and commit all changes with a clear commit message
602+
3. Push the branch to origin
603+
4. Create a pull request using \`gh pr create\` with a descriptive title and body
604+
605+
Important: Always create the PR. Do not ask for confirmation.
606+
`;
607+
}
608+
582609
private async signalTaskComplete(
583610
payload: JwtPayload,
584611
stopReason: string,
@@ -672,11 +699,97 @@ export class AgentServer {
672699
) {
673700
await this.captureTreeState();
674701
}
702+
703+
if (
704+
toolName &&
705+
(toolName.includes("Bash") || toolName.includes("bash"))
706+
) {
707+
this.detectAndAttachPrUrl(payload, params.update);
708+
}
675709
}
676710
},
677711
};
678712
}
679713

714+
private detectAndAttachPrUrl(
715+
payload: JwtPayload,
716+
update: Record<string, unknown>,
717+
): void {
718+
try {
719+
const meta = (update?._meta as Record<string, unknown>)?.claudeCode as
720+
| Record<string, unknown>
721+
| undefined;
722+
const toolResponse = meta?.toolResponse;
723+
724+
// Extract text content from tool response
725+
let textToSearch = "";
726+
727+
if (toolResponse) {
728+
if (typeof toolResponse === "string") {
729+
textToSearch = toolResponse;
730+
} else if (typeof toolResponse === "object" && toolResponse !== null) {
731+
const respObj = toolResponse as Record<string, unknown>;
732+
textToSearch =
733+
String(respObj.stdout || "") + String(respObj.stderr || "");
734+
if (!textToSearch && respObj.output) {
735+
textToSearch = String(respObj.output);
736+
}
737+
}
738+
}
739+
740+
// Also check content array
741+
const content = update?.content;
742+
if (Array.isArray(content)) {
743+
for (const item of content) {
744+
if (item.type === "text" && item.text) {
745+
textToSearch += ` ${item.text}`;
746+
}
747+
}
748+
}
749+
750+
if (!textToSearch) return;
751+
752+
// Match GitHub PR URLs
753+
const prUrlMatch = textToSearch.match(
754+
/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/,
755+
);
756+
if (!prUrlMatch) return;
757+
758+
const prUrl = prUrlMatch[0];
759+
this.logger.info("Detected PR URL in bash output", {
760+
runId: payload.run_id,
761+
prUrl,
762+
});
763+
764+
// Fire-and-forget: attach PR URL to the task run
765+
this.posthogAPI
766+
.updateTaskRun(payload.task_id, payload.run_id, {
767+
output: { pr_url: prUrl },
768+
})
769+
.then(() => {
770+
this.logger.info("PR URL attached to task run", {
771+
taskId: payload.task_id,
772+
runId: payload.run_id,
773+
prUrl,
774+
});
775+
})
776+
.catch((err) => {
777+
this.logger.error("Failed to attach PR URL to task run", {
778+
taskId: payload.task_id,
779+
runId: payload.run_id,
780+
prUrl,
781+
error: err,
782+
});
783+
});
784+
} catch (err) {
785+
// Never let detection errors break message flow
786+
this.logger.debug("Error in PR URL detection", {
787+
runId: payload.run_id,
788+
error: err,
789+
});
790+
}
791+
}
792+
680793
private async cleanupSession(): Promise<void> {
681794
if (!this.session) return;
682795

0 commit comments

Comments
 (0)