Skip to content

Commit 1748212

Browse files
authored
Merge branch 'main' into feat/per-project-dockerfile
2 parents 2487949 + a0f5280 commit 1748212

7 files changed

Lines changed: 403 additions & 113 deletions

File tree

electron/ipc/steps.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const watchers = new Map<string, StepsWatcher>();
1818
* timestamp overwritten with the host clock — regardless of what the AI wrote.
1919
* Entries below that index keep their existing timestamps (they were stamped by
2020
* us on a previous read, possibly before an app restart).
21+
*
22+
* A missing map entry means we haven't observed this task yet in this process —
23+
* on that first read we only fill in missing timestamps and seed the counter,
24+
* so existing stamps from prior sessions survive a restart.
2125
*/
2226
const processedCount = new Map<string, number>();
2327

@@ -36,15 +40,17 @@ function sendStepsContent(win: BrowserWindow, taskId: string, stepsFile: string)
3640
* changed; the subsequent watcher event finds nothing new and stops.
3741
*/
3842
function applyTimestamps(steps: unknown[], stepsFile: string, taskId: string): void {
39-
const prevCount = processedCount.get(taskId) ?? 0;
43+
const firstRun = !processedCount.has(taskId);
44+
const prevCount = processedCount.get(taskId) ?? steps.length;
4045
const now = new Date().toISOString();
4146
let dirty = false;
4247

4348
for (let i = 0; i < steps.length; i++) {
4449
const entry = steps[i];
4550
if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) {
4651
const e = entry as Record<string, unknown>;
47-
if (i >= prevCount || !e['timestamp']) {
52+
const isNew = !firstRun && i >= prevCount;
53+
if (isNew || !e['timestamp']) {
4854
e['timestamp'] = now;
4955
dirty = true;
5056
}

src/components/TaskAITerminal.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Show, For, createSignal, onMount, onCleanup } from 'solid-js';
1+
import { Show, For, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
22

33
import {
44
store,
@@ -27,13 +27,38 @@ interface TaskAITerminalProps {
2727
task: Task;
2828
isActive: boolean;
2929
promptHandle: PromptInputHandle | undefined;
30+
/** Receives a function that scrolls the AI terminal to the moment a given step
31+
* index was recorded. Called with `undefined` when the terminal unmounts. */
32+
onStepJumpReady?: (jump: ((stepIndex: number) => boolean) | undefined) => void;
3033
}
3134

3235
export function TaskAITerminal(props: TaskAITerminalProps) {
3336
onCleanup(() => unregisterFocusFn(`${props.task.id}:ai-terminal`));
3437

3538
const dockerOverlayLabel = () => getTaskDockerOverlayLabel(props.task.dockerSource);
3639

40+
// Step bookmarks — TerminalView hands us a mark/jump API once the xterm
41+
// instance is ready. We mark each newly arrived step at the current scrollback
42+
// position, and expose `jump` upward so the steps panel can scroll back to it.
43+
// On agent restart the inner TerminalView remounts (keyed Show), so the API
44+
// reference must be reset to undefined and lastMarkedLen back to 0 — the new
45+
// mount will then re-backfill markers for all existing steps.
46+
let stepNav: { mark: (i: number) => void; jump: (i: number) => boolean } | undefined;
47+
let lastMarkedLen = 0;
48+
onCleanup(() => props.onStepJumpReady?.(undefined));
49+
50+
createEffect(() => {
51+
const len = props.task.stepsContent?.length ?? 0;
52+
if (!stepNav) return; // Don't advance lastMarkedLen until a terminal is ready.
53+
if (len <= lastMarkedLen) {
54+
lastMarkedLen = len;
55+
return;
56+
}
57+
for (let i = lastMarkedLen; i < len; i++) stepNav.mark(i);
58+
lastMarkedLen = len;
59+
});
60+
61+
// --- Markdown file viewer ---
3762
const [mdViewerContent, setMdViewerContent] = createSignal('');
3863
const [mdViewerFileName, setMdViewerFileName] = createSignal('');
3964
const [mdViewerOpen, setMdViewerOpen] = createSignal(false);
@@ -200,6 +225,24 @@ export function TaskAITerminal(props: TaskAITerminalProps) {
200225
onFileLink={handleFileLink}
201226
onPromptDetected={(text) => setLastPrompt(props.task.id, text)}
202227
onReady={(focusFn) => registerFocusFn(`${props.task.id}:ai-terminal`, focusFn)}
228+
onStepNavReady={(api) => {
229+
if (!api) {
230+
// TerminalView is unmounting (agent restart). Drop the stale API
231+
// and reset the watermark so the next mount's backfill marks every step.
232+
stepNav = undefined;
233+
lastMarkedLen = 0;
234+
props.onStepJumpReady?.(undefined);
235+
return;
236+
}
237+
stepNav = api;
238+
// Backfill markers for steps that already exist when the terminal mounts.
239+
// They all anchor to the current line — best-effort, since we can't know
240+
// where each historical step was originally written.
241+
const len = props.task.stepsContent?.length ?? 0;
242+
for (let i = 0; i < len; i++) api.mark(i);
243+
lastMarkedLen = len;
244+
props.onStepJumpReady?.(api.jump);
245+
}}
203246
fontSize={14}
204247
/>
205248
</Show>

src/components/TaskPanel.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import {
55
setActiveTask,
66
clearInitialPrompt,
77
clearPrefillPrompt,
8+
setPrefillPrompt,
89
getProject,
910
setTaskFocusedPanel,
1011
triggerFocus,
1112
clearPendingAction,
1213
showNotification,
13-
sendPrompt,
1414
} from '../store/store';
1515
import { useFocusRegistration } from '../lib/focus-registration';
1616
import { ResizablePanel, type PanelChild } from './ResizablePanel';
@@ -55,6 +55,8 @@ export function TaskPanel(props: TaskPanelProps) {
5555
const [selectedCommit, setSelectedCommit] = createSignal<string | null>(null);
5656
const [editingProjectId, setEditingProjectId] = createSignal<string | null>(null);
5757
const [stepsNaturalHeight, setStepsNaturalHeight] = createSignal(110);
58+
// Stored as a ref (not a signal) — only invoked from user click handlers, never read reactively.
59+
let stepJumpFn: ((stepIndex: number) => boolean) | undefined;
5860
let panelRef!: HTMLDivElement;
5961
let promptRef: HTMLTextAreaElement | undefined;
6062
let titleEditHandle: EditableTextHandle | undefined;
@@ -207,7 +209,12 @@ export function TaskPanel(props: TaskPanelProps) {
207209
onFileClick={(file) => setDiffScrollTarget(file)}
208210
onNaturalHeight={setStepsNaturalHeight}
209211
onNextClick={(text) => {
210-
void sendPrompt(props.task.id, firstAgentId(), text);
212+
setPrefillPrompt(props.task.id, text);
213+
triggerFocus(`${props.task.id}:prompt`);
214+
}}
215+
onJumpToStep={(idx) => {
216+
const ok = stepJumpFn?.(idx);
217+
if (ok) setTaskFocusedPanel(props.task.id, 'ai-terminal');
211218
}}
212219
/>
213220
),
@@ -251,7 +258,12 @@ export function TaskPanel(props: TaskPanelProps) {
251258
id: 'ai-terminal',
252259
minSize: 80,
253260
content: () => (
254-
<TaskAITerminal task={props.task} isActive={props.isActive} promptHandle={promptHandle} />
261+
<TaskAITerminal
262+
task={props.task}
263+
isActive={props.isActive}
264+
promptHandle={promptHandle}
265+
onStepJumpReady={(fn) => (stepJumpFn = fn)}
266+
/>
255267
),
256268
};
257269
}

0 commit comments

Comments
 (0)