Skip to content

Commit 19c0d86

Browse files
committed
feat: preview session on task input page
1 parent e19e3b1 commit 19c0d86

11 files changed

Lines changed: 369 additions & 75 deletions

File tree

apps/twig/src/main/services/agent/service.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -438,13 +438,16 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
438438
const mockNodeDir = this.setupMockNodeEnvironment(taskRunId);
439439
this.setupEnvironment(credentials, mockNodeDir);
440440

441+
// Preview sessions don't persist logs — no real task exists
442+
const isPreview = taskId === "__preview__";
443+
441444
// OTEL log pipeline or legacy S3 writer if FF false
442-
const useOtelPipeline = await this.isFeatureFlagEnabled(
443-
"twig-agent-logs-pipeline",
444-
);
445+
const useOtelPipeline = isPreview
446+
? false
447+
: await this.isFeatureFlagEnabled("twig-agent-logs-pipeline");
445448

446449
log.info("Agent log transport", {
447-
transport: useOtelPipeline ? "otel" : "s3",
450+
transport: isPreview ? "none" : useOtelPipeline ? "otel" : "s3",
448451
taskId,
449452
taskRunId,
450453
});
@@ -462,6 +465,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
462465
logsPath: "/i/v1/agent-logs",
463466
}
464467
: undefined,
468+
skipLogPersistence: isPreview,
465469
debug: !app.isPackaged,
466470
onLog: onAgentLog,
467471
});

apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ interface EditorToolbarProps {
1313
onAttachFiles?: (files: File[]) => void;
1414
attachTooltip?: string;
1515
iconSize?: number;
16+
/** Hide model and reasoning selectors (when rendered separately) */
17+
hideSelectors?: boolean;
1618
}
1719

1820
export function EditorToolbar({
@@ -23,6 +25,7 @@ export function EditorToolbar({
2325
onAttachFiles,
2426
attachTooltip = "Attach file",
2527
iconSize = 14,
28+
hideSelectors = false,
2629
}: EditorToolbarProps) {
2730
const fileInputRef = useRef<HTMLInputElement>(null);
2831

@@ -68,8 +71,16 @@ export function EditorToolbar({
6871
<Paperclip size={iconSize} weight="bold" />
6972
</IconButton>
7073
</Tooltip>
71-
<ModelSelector taskId={taskId} adapter={adapter} disabled={disabled} />
72-
<ReasoningLevelSelector taskId={taskId} disabled={disabled} />
74+
{!hideSelectors && (
75+
<>
76+
<ModelSelector
77+
taskId={taskId}
78+
adapter={adapter}
79+
disabled={disabled}
80+
/>
81+
<ReasoningLevelSelector taskId={taskId} disabled={disabled} />
82+
</>
83+
)}
7384
</Flex>
7485
);
7586
}

apps/twig/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ const DEFAULT_STYLE: ModeStyle = {
5555
};
5656

5757
interface ModeIndicatorInputProps {
58-
modeOption: SessionConfigOption;
58+
modeOption: SessionConfigOption | undefined;
59+
onCycleMode?: () => void;
5960
}
6061

6162
function flattenOptions(
@@ -70,7 +71,12 @@ function flattenOptions(
7071
return options as SessionConfigSelectOption[];
7172
}
7273

73-
export function ModeIndicatorInput({ modeOption }: ModeIndicatorInputProps) {
74+
export function ModeIndicatorInput({
75+
modeOption,
76+
onCycleMode,
77+
}: ModeIndicatorInputProps) {
78+
if (!modeOption) return null;
79+
7480
const id = modeOption.currentValue;
7581

7682
const style = MODE_STYLES[id] ?? DEFAULT_STYLE;
@@ -80,7 +86,13 @@ export function ModeIndicatorInput({ modeOption }: ModeIndicatorInputProps) {
8086
const label = option?.name ?? id;
8187

8288
return (
83-
<Flex align="center" justify="between" py="1">
89+
<Flex
90+
align="center"
91+
justify="between"
92+
py="1"
93+
style={onCycleMode ? { cursor: "pointer" } : undefined}
94+
onClick={onCycleMode}
95+
>
8496
<Flex align="center" gap="1">
8597
<Text
8698
size="1"

apps/twig/src/renderer/features/sessions/service/service.ts

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import { ANALYTICS_EVENTS } from "@/types/analytics";
4646

4747
const log = logger.scope("session-service");
4848

49+
export const PREVIEW_TASK_ID = "__preview__";
50+
4951
interface AuthCredentials {
5052
apiKey: string;
5153
apiHost: string;
@@ -60,6 +62,7 @@ interface ConnectParams {
6062
executionMode?: ExecutionMode;
6163
adapter?: "claude" | "codex";
6264
model?: string;
65+
reasoningLevel?: string;
6366
}
6467

6568
// --- Singleton Service Instance ---
@@ -95,6 +98,8 @@ export class SessionService {
9598
permission?: { unsubscribe: () => void };
9699
}
97100
>();
101+
/** Version counter to discard stale preview session results */
102+
private previewVersion = 0;
98103

99104
/**
100105
* Connect to a task session.
@@ -136,8 +141,15 @@ export class SessionService {
136141
}
137142

138143
private async doConnect(params: ConnectParams): Promise<void> {
139-
const { task, repoPath, initialPrompt, executionMode, adapter, model } =
140-
params;
144+
const {
145+
task,
146+
repoPath,
147+
initialPrompt,
148+
executionMode,
149+
adapter,
150+
model,
151+
reasoningLevel,
152+
} = params;
141153
const { id: taskId, latest_run: latestRun } = task;
142154
const taskTitle = task.title || task.description || "Task";
143155

@@ -214,6 +226,7 @@ export class SessionService {
214226
executionMode,
215227
adapter,
216228
model,
229+
reasoningLevel,
217230
);
218231
}
219232
} catch (error) {
@@ -360,6 +373,7 @@ export class SessionService {
360373
executionMode?: ExecutionMode,
361374
adapter?: "claude" | "codex",
362375
model?: string,
376+
reasoningLevel?: string,
363377
): Promise<void> {
364378
if (!auth.client) {
365379
throw new Error("Unable to reach server. Please check your connection.");
@@ -419,6 +433,15 @@ export class SessionService {
419433
);
420434
}
421435

436+
// Set reasoning level if provided (e.g., from Codex adapter's preview session)
437+
if (reasoningLevel) {
438+
await this.setSessionConfigOptionByCategory(
439+
taskId,
440+
"thought_level",
441+
reasoningLevel,
442+
);
443+
}
444+
422445
if (initialPrompt?.length) {
423446
await this.sendPrompt(taskId, initialPrompt);
424447
}
@@ -442,6 +465,127 @@ export class SessionService {
442465
sessionStoreSetters.removeSession(session.taskRunId);
443466
}
444467

468+
// --- Preview Session Management ---
469+
470+
/**
471+
* Start a lightweight preview session for the task input page.
472+
* This session is used solely to retrieve adapter-specific config options
473+
* (models, modes, reasoning levels) without creating a real PostHog task.
474+
*
475+
* Uses a version counter to prevent race conditions when rapidly switching
476+
* adapters — stale results from a previous start are discarded.
477+
*/
478+
async startPreviewSession(params: {
479+
adapter: "claude" | "codex";
480+
repoPath?: string;
481+
}): Promise<void> {
482+
// Increment version to invalidate any in-flight start
483+
const version = ++this.previewVersion;
484+
485+
// Cancel any existing preview session first
486+
await this.cancelPreviewSession();
487+
488+
// Check if a newer start was requested while we were cancelling
489+
if (version !== this.previewVersion) {
490+
log.info("Preview session start superseded, skipping", { version });
491+
return;
492+
}
493+
494+
const auth = this.getAuthCredentials();
495+
if (!auth) {
496+
log.info("Skipping preview session - not authenticated");
497+
return;
498+
}
499+
500+
const taskRunId = `preview-${crypto.randomUUID()}`;
501+
const session = this.createBaseSession(
502+
taskRunId,
503+
PREVIEW_TASK_ID,
504+
"Preview",
505+
);
506+
session.adapter = params.adapter;
507+
sessionStoreSetters.setSession(session);
508+
509+
try {
510+
const result = await trpcVanilla.agent.start.mutate({
511+
taskId: PREVIEW_TASK_ID,
512+
taskRunId,
513+
repoPath: params.repoPath || "~",
514+
apiKey: auth.apiKey,
515+
apiHost: auth.apiHost,
516+
projectId: auth.projectId,
517+
adapter: params.adapter,
518+
});
519+
520+
// Check again after the async start — a newer call may have superseded us
521+
if (version !== this.previewVersion) {
522+
log.info(
523+
"Preview session start superseded after agent.start, cleaning up stale session",
524+
{ taskRunId, version },
525+
);
526+
// Clean up the session we just started but is now stale
527+
trpcVanilla.agent.cancel
528+
.mutate({ sessionId: taskRunId })
529+
.catch((err) => {
530+
log.warn("Failed to cancel stale preview session", {
531+
taskRunId,
532+
error: err,
533+
});
534+
});
535+
sessionStoreSetters.removeSession(taskRunId);
536+
return;
537+
}
538+
539+
const configOptions = result.configOptions as
540+
| SessionConfigOption[]
541+
| undefined;
542+
543+
sessionStoreSetters.updateSession(taskRunId, {
544+
status: "connected",
545+
channel: result.channel,
546+
configOptions,
547+
});
548+
549+
this.subscribeToChannel(taskRunId);
550+
551+
log.info("Preview session started", {
552+
taskRunId,
553+
adapter: params.adapter,
554+
configOptionsCount: configOptions?.length ?? 0,
555+
});
556+
} catch (error) {
557+
// Only clean up if we're still the current version
558+
if (version === this.previewVersion) {
559+
log.error("Failed to start preview session", { error });
560+
sessionStoreSetters.removeSession(taskRunId);
561+
}
562+
}
563+
}
564+
565+
/**
566+
* Cancel and clean up the preview session.
567+
* Unsubscribes and removes from store first (so nothing writes to the old
568+
* session), then awaits the cancel on the main process.
569+
*/
570+
async cancelPreviewSession(): Promise<void> {
571+
const session = sessionStoreSetters.getSessionByTaskId(PREVIEW_TASK_ID);
572+
if (!session) return;
573+
574+
const { taskRunId } = session;
575+
576+
// Unsubscribe and remove from store first so nothing writes to the old session
577+
this.unsubscribeFromChannel(taskRunId);
578+
sessionStoreSetters.removeSession(taskRunId);
579+
580+
try {
581+
await trpcVanilla.agent.cancel.mutate({ sessionId: taskRunId });
582+
} catch (error) {
583+
log.warn("Failed to cancel preview session", { taskRunId, error });
584+
}
585+
586+
log.info("Preview session cancelled", { taskRunId });
587+
}
588+
445589
// --- Subscription Management ---
446590

447591
private subscribeToChannel(taskRunId: string): void {
@@ -511,6 +655,7 @@ export class SessionService {
511655
}
512656

513657
this.connectingTasks.clear();
658+
this.previewVersion = 0;
514659
}
515660

516661
private handleSessionEvent(taskRunId: string, acpMsg: AcpMessage): void {

0 commit comments

Comments
 (0)