Skip to content
Merged
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
11 changes: 9 additions & 2 deletions packages/app/src/components/orchestrate/phase-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ export const PhaseBar: Component<PhaseBarProps> = (props) => {
if (props.runStatus === "failed" || props.runStatus === "killed") return true
return !props.runStatus && props.phase === "failed"
}
const showResume = () => !!props.onResume && !!props.runId && canResume()
const showPause = () =>
!!props.onPause
&& !!props.runId
&& isActive()
&& !isPaused()
&& !showResume()
const pendingQuestions = () => props.pendingQuestionCount ?? 0
const isActionPending = () =>
!!props.isPausing || !!props.isResuming || !!props.isDeleting || !!props.isDeploying
Expand Down Expand Up @@ -208,7 +215,7 @@ export const PhaseBar: Component<PhaseBarProps> = (props) => {
</div>

<div class="ml-auto flex shrink-0 items-center justify-end gap-1.5">
<Show when={isActive() && !isPaused() && props.onPause && props.runId}>
<Show when={showPause()}>
<Tooltip placement="top" value="Pause run">
<Button
size="small"
Expand All @@ -223,7 +230,7 @@ export const PhaseBar: Component<PhaseBarProps> = (props) => {
</Tooltip>
</Show>

<Show when={canResume() && props.onResume && props.runId}>
<Show when={showResume()}>
<Tooltip placement="top" value="Resume run">
<Button
size="small"
Expand Down
60 changes: 50 additions & 10 deletions packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}

const looksLikeGlyphNoiseChunk = (value: string): boolean => {
const trimmed = value.trim()
if (trimmed.length < 64) return false
const chars = [...trimmed]
const total = chars.length
let asciiCount = 0
let whitespaceCount = 0
let astralCount = 0
let cjkCount = 0
for (const ch of chars) {
const code = ch.codePointAt(0) ?? 0
if (code >= 0x20 && code <= 0x7e) asciiCount += 1
if (/\s/u.test(ch)) whitespaceCount += 1
if (code > 0xffff) astralCount += 1
if (code >= 0x3400 && code <= 0x9fff) cjkCount += 1
}
const cjkNoise = cjkCount >= 48 && cjkCount / Math.max(1, total) > 0.72 && asciiCount < 10
const astralNoise =
astralCount >= 40 && astralCount / Math.max(1, total) > 0.68 && asciiCount < 6 && whitespaceCount < 4
return cjkNoise || astralNoise
}

const sanitizeTerminalReplay = (value: string): string => {
if (!value) return value
const lines = value.split("\n")
const filtered = lines.filter((line) => !looksLikeGlyphNoiseChunk(line))
const joined = filtered.join("\n")
if (!joined) return ""
return looksLikeGlyphNoiseChunk(joined) ? "" : joined
}

const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
Expand Down Expand Up @@ -314,7 +345,8 @@ export const Terminal = (props: TerminalProps) => {

const once = { value: false }

const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreRaw = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restore = sanitizeTerminalReplay(restoreRaw)
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Expand Down Expand Up @@ -459,22 +491,30 @@ export const Terminal = (props: TerminalProps) => {
if (event.data instanceof ArrayBuffer) {
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
if (bytes[0] === 0) {
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
return
}

const binaryText = decoder.decode(bytes)
if (!binaryText || looksLikeGlyphNoiseChunk(binaryText)) return
output?.push(binaryText)
cursor += binaryText.length
return
}

const data = typeof event.data === "string" ? event.data : ""
if (!data) return
if (looksLikeGlyphNoiseChunk(data)) return
output?.push(data)
cursor += data.length
}
Expand Down
44 changes: 37 additions & 7 deletions packages/app/src/context/orchestrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ export interface OrchestrateRunSnapshot {
tokenUsage?: { inputTokens: number; outputTokens: number; totalTokens: number }
}

const RUN_HISTORY_PREFETCH_LIMIT = 5

// ─── Local helpers ─────────────────────────────────────────────────────────────

const SseTaskUpdatedEventSchema = z.object({
Expand Down Expand Up @@ -935,6 +937,7 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
// Run history
const [runs, setRuns] = createStore<OrchestrateRunSnapshot[]>([])
const [selectedRunId, setSelectedRunId] = createSignal<string | undefined>(undefined)
const runHistoryHydrationInFlight = new Set<string>()
let runStartTime = 0
let eventSourceCleanup: (() => void) | undefined

Expand Down Expand Up @@ -1024,6 +1027,18 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
return snapshot
}

async function hydrateRunSnapshotTasks(runId: string): Promise<void> {
const normalizedRunId = runId.trim()
if (!normalizedRunId || runHistoryHydrationInFlight.has(normalizedRunId)) return

runHistoryHydrationInFlight.add(normalizedRunId)
try {
await fetchRunSnapshotFromServer(normalizedRunId)
} finally {
runHistoryHydrationInFlight.delete(normalizedRunId)
}
}

async function refreshRunHistoryFromServer(): Promise<any[]> {
const base = sdk.url ?? ""
const directory = sdk.directory
Expand All @@ -1035,6 +1050,7 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp

const payload = await response.json().catch(() => []) as unknown
const serverRuns = Array.isArray(payload) ? payload : []
let prefetchRunIds: string[] = []

setRuns(
produce((draft) => {
Expand All @@ -1053,10 +1069,18 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
})

merged.sort((left, right) => right.time.started - left.time.started)
prefetchRunIds = merged
.slice(0, RUN_HISTORY_PREFETCH_LIMIT)
.filter((run) => run.tasks.length === 0)
.map((run) => run.id)
draft.splice(0, draft.length, ...merged)
}),
)

for (const runId of prefetchRunIds) {
void hydrateRunSnapshotTasks(runId).catch(() => {})
}

return serverRuns
}

Expand Down Expand Up @@ -1102,7 +1126,7 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
if (!runId) return
const snapshot = runs.find((run) => run.id === runId)
if (!snapshot || snapshot.tasks.length > 0) return
void fetchRunSnapshotFromServer(runId).catch(() => {})
void hydrateRunSnapshotTasks(runId).catch(() => {})
})

// ── SSE Event Stream ──────────────────────────────────────────────────
Expand Down Expand Up @@ -2465,6 +2489,12 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
const hasAnyOutstanding = state.tasks.some((task) =>
task.status === "pending" || task.status === "in_progress" || task.status === "blocked",
)
const hasMergeRelatedFailure = state.tasks.some((task) => {
if (task.status !== "failed") return false
if (task.agentBranch || task.worktreePath || typeof task.metrics?.ingestMs === "number") return true
const corpus = `${task.error ?? ""}\n${task.result ?? ""}`.toLowerCase()
return /\bmerge ingest\b|\bdeterministic merge\b|\bmerge conflict\b|\bunmerged\b|\bmerge blocked\b|\bconflict resolver\b/.test(corpus)
})

const orchestratorStatus: AgentNode["status"] = runFailed
? "failed"
Expand Down Expand Up @@ -2519,12 +2549,12 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
// Ingest/Merge agent node — visible when the ingest session exists
const shouldShowIngestNode = !!state.ingestSessionId
const ingestSessionStatus = state.ingestSessionId ? sync.data.session_status[state.ingestSessionId] : undefined
const ingestStatus: AgentNode["status"] = runFailed
? "failed"
: runPaused
? "paused"
: ingestSessionStatus?.type === "busy" || ingestSessionStatus?.type === "retry"
? "busy"
const ingestStatus: AgentNode["status"] = runPaused
? "paused"
: ingestSessionStatus?.type === "busy" || ingestSessionStatus?.type === "retry"
? "busy"
: hasMergeRelatedFailure
? "failed"
: state.phase === "complete"
? "done"
: hasAnyCompleted && hasAnyOutstanding
Expand Down
Loading