Skip to content
Open
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop/electron/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
getHost,
scheduledRunsBySession,
isQuitting: dependencies.isQuitting,
onRunChanged: (change) => sendToRenderer(IPC.event.scheduledChanged, { change }),
invoke: async (channel, args) => {
const handler = ipcHandlers.get(channel);
if (!handler) throw new Error("scheduled prompt handler unavailable");
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/electron/main/ipc/scheduled-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IPC } from "@pi-desktop/shared";
import type { ScheduledRunChange } from "@pi-desktop/shared";
import type { HostProcess } from "../host-process";
import type { IpcRegistrar } from "./types";
import { executeScheduledTask } from "../runtime/scheduled-runner";
Expand All @@ -9,6 +10,9 @@ export type ScheduledIpcDependencies = {
scheduledRunsBySession: Map<string, string>;
invoke: (channel: string, args: readonly unknown[]) => Promise<unknown>;
isQuitting: () => boolean;
/** Announces a run the moment the host accepts it, so a project row can
* report it while the automation transcript stays out of the session list. */
onRunChanged?: (change: ScheduledRunChange) => void;
};

export function registerScheduledIpc({
Expand All @@ -17,6 +21,7 @@ export function registerScheduledIpc({
scheduledRunsBySession,
invoke,
isQuitting,
onRunChanged,
}: ScheduledIpcDependencies): void {
// The Scheduled workspace renders one task's own history and one newest run
// per task for the column, so the caller may scope or summarize the read. The
Expand Down Expand Up @@ -46,6 +51,7 @@ export function registerScheduledIpc({
return executeScheduledTask({
host, id, automatic, runs: scheduledRunsBySession,
isCurrent: () => !isQuitting() && getHost() === host,
...(onRunChanged ? { onChanged: onRunChanged } : {}),
prompt: (sessionId, content) => invoke(IPC.invoke.agentPrompt, [{ sessionId, content }]),
});
});
Expand Down Expand Up @@ -80,6 +86,17 @@ export function registerScheduledIpc({
runId: string;
}>("scheduled.run", { id });
scheduledRunsBySession.set(result.sessionId, result.runId);
// A manual run does not pass through the dispatcher, so it announces itself
// here; settlement still comes from the turn's own finalization (#1441).
const task = (result.task ?? {}) as { title?: string; workspacePath?: string };
onRunChanged?.({
runId: result.runId,
sessionId: result.sessionId,
taskId: id,
...(task.title ? { taskTitle: task.title } : {}),
...(task.workspacePath ? { projectPath: task.workspacePath } : {}),
status: "running",
});
return result;
});
}
23 changes: 18 additions & 5 deletions apps/desktop/electron/main/runtime/plans.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ErrorCodes, IPC, type AgentEventEnvelope, type AppNotification, type PlanExecution, type PlanExecutionFinishStatus, type UiMessage } from "@pi-desktop/shared";
import { ErrorCodes, IPC, type AgentEventEnvelope, type AppNotification, type PlanExecution, type PlanExecutionFinishStatus, type ScheduledRunChange, type UiMessage } from "@pi-desktop/shared";
import { executionFromResponse, executionListFromResponse, planExecutionFromUnknown } from "@pi-desktop/host-runtime";
import type { RuntimeState } from "./context";
import type {
Expand Down Expand Up @@ -237,14 +237,27 @@ function finishTurn(
}

if (runId && runtimeState.host) {
await runtimeState.host
const settled = await runtimeState.host
.call("scheduled.finishRun", { runId, status: reason, errorCode })
.catch((e) =>
.then(() => true)
.catch((e) => {
logger.app("persistence", "warn", "finishRun failed", {
sessionId: id,
data: String(e),
}),
);
});
return false;
});
if (settled) {
// The run's conversation is not in the session list, so its project
// row learns the result here (issue #1441).
sendToRenderer(IPC.event.scheduledChanged, {
change: {
runId,
sessionId: id,
status: reason === "completed" ? "completed" : "error",
} satisfies ScheduledRunChange,
});
}
}
} finally {
// Do not release local ownership until the durable endTurn request above
Expand Down
36 changes: 33 additions & 3 deletions apps/desktop/electron/main/runtime/scheduled-runner.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
type Host = { call<T>(method: string, params?: Record<string, unknown>): Promise<T> };
export type ScheduledLaunch = { sessionId: string; prompt: string; runId: string };
import type { ScheduledRunChange } from "@pi-desktop/shared";

/** Runs through the same prompt entry point as an interactive desktop turn. */
export type ScheduledLaunch = {
sessionId: string;
prompt: string;
runId: string;
/** The dispatched task, so a project row can name the run it reports. */
task?: { title?: string; workspacePath?: string };
};

/**
* Runs through the same prompt entry point as an interactive desktop turn.
*
* The run's transcript stays out of the session list (#1298), so a project row
* learns about the run from here (issue #1441): `onChanged` announces the run
* the moment the host accepts it, settlement is announced where the turn ends
* (`plans.ts`, right after `scheduled.finishRun`), and a dispatch failure
* announces itself below.
*/
export async function executeScheduledTask(options: {
host: Host;
isCurrent: () => boolean;
id: string;
automatic: boolean;
runs: Map<string, string>;
prompt: (sessionId: string, content: string) => Promise<unknown>;
onChanged?: (change: ScheduledRunChange) => void;
}): Promise<ScheduledLaunch> {
const { host, id, automatic, runs, prompt, isCurrent } = options;
const { host, id, automatic, runs, prompt, isCurrent, onChanged } = options;
const launch = await host.call<ScheduledLaunch>("scheduled.run", { id, automatic });
// `scheduled.run` answers with the task it dispatched, so the run's identity
// for the project row costs no further read.
const task = launch.task;
const identity: ScheduledRunChange = {
runId: launch.runId,
sessionId: launch.sessionId,
taskId: id,
...(task?.title ? { taskTitle: task.title } : {}),
...(task?.workspacePath ? { projectPath: task.workspacePath } : {}),
status: "running",
};
onChanged?.(identity);
try {
if (!isCurrent()) throw new Error("scheduled runtime stopped");
runs.set(launch.sessionId, launch.runId);
await prompt(launch.sessionId, launch.prompt);
return launch;
} catch (error) {
if (runs.get(launch.sessionId) === launch.runId) runs.delete(launch.sessionId);
onChanged?.({ ...identity, status: "error" });
try {
await host.call("scheduled.finishRun", {
runId: launch.runId,
Expand Down
73 changes: 71 additions & 2 deletions apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
sessionPinned,
} from "../lib/sidebar-session-groups";
import { listableSessions } from "../lib/session-origin";
import { projectRunningStatus } from "../lib/sidebar-project-status";
import {
composerDropItems,
hasComposerFileDrag,
Expand Down Expand Up @@ -240,6 +241,22 @@ export function Sidebar({
const runningSessions = useAppStore((s) => s.runningSessions);
const sessionOutcomes = useAppStore((s) => s.sessionOutcomes);
const pendingPermissions = useAppStore((s) => s.pendingPermissions);
const pendingAsks = useAppStore((s) => s.pendingAsks);
const pendingPlans = useAppStore((s) => s.pendingPlans);
const scheduledRuns = useAppStore((s) => s.scheduledRuns);

/** Sessions that cannot continue until the reader answers (issue #1441). */
const attentionSessionIds = useMemo(() => {
const ids = new Set<string>();
for (const [sessionId, queue] of Object.entries(pendingPermissions)) {
if (queue.length > 0) ids.add(sessionId);
}
for (const [sessionId, queue] of Object.entries(pendingAsks)) {
if (queue.length > 0) ids.add(sessionId);
}
for (const sessionId of Object.keys(pendingPlans)) ids.add(sessionId);
return ids;
}, [pendingPermissions, pendingAsks, pendingPlans]);
const setPage = useAppStore((s) => s.setPage);
const navBack = useAppStore((s) => s.navBack);
const canNavBack = useAppStore((s) => s.canNavBack);
Expand Down Expand Up @@ -1818,6 +1835,44 @@ export function Sidebar({

const renderProjectGroup = (entry: ProjectEntry) => {
const collapsedProject = entry.meta.collapsed ?? projectCollapsed[entry.key] ?? false;

// A collapsed project still reports what it is doing (issue #1441). The
// aggregate reads state this row already subscribes to: its own listed
// sessions, the unfiltered session list (scheduled runs stay in the store),
// the running map, unread outcomes, and the pending interactive prompts.
const status = projectRunningStatus({
sessions: entry.sessions,
allSessions: sessions,
projectPath: entry.path,
runningSessions,
outcomes: sessionOutcomes,
attentionSessionIds,
scheduledRuns: Object.values(scheduledRuns),
});
const statusParts: string[] = [];
if (status.needsAttention > 0) {
statusParts.push(t("nav.projectStatusAttention", { count: status.needsAttention }));
}
if (status.running > 0) {
statusParts.push(t("nav.projectStatusRunning", { count: status.running }));
}
if (status.scheduledRunning > 0) {
statusParts.push(t("nav.projectStatusScheduled", { count: status.scheduledRunning }));
}
if (status.failed > 0) {
statusParts.push(t("nav.projectStatusFailed", { count: status.failed }));
}
if (status.finished > 0 && !status.settled) {
statusParts.push(t("nav.projectStatusFinished", { count: status.finished }));
}
if (status.settled) {
statusParts.push(t("nav.projectStatusSettled", { count: status.finished + status.failed }));
}
// One trailing mark, never a second label (issue #1441): the session rows'
// own dot while work is in flight, and a count only once a result is
// waiting to be read. Everything else lives in the row's hover sentence.
const statusSummary = statusParts.join(" · ");
const unreadResults = status.finished + status.failed;
const projectId = projectDomId(entry.key);
const isMenuOpen = projectMenu === entry.key;

Expand Down Expand Up @@ -1918,9 +1973,9 @@ export function Sidebar({
type="button"
id={projectId}
className="sidebar-session-group-title project-toggle"
tooltip={entry.path}
tooltip={statusSummary ? `${entry.path}\n${statusSummary}` : entry.path}
tooltipDelayMs={500}
tooltipClassName="ui-tooltip-path"
tooltipClassName="ui-tooltip-path ui-tooltip-path-status"
ariaLabel={entry.name}
aria-describedby={`${projectId}-path-description`}
aria-expanded={!collapsedProject}
Expand Down Expand Up @@ -1958,11 +2013,25 @@ export function Sidebar({
)}
<span>{entry.name}</span>
{entry.active ? <span className="sidebar-project-active-dot" aria-label={t("project.active", { defaultValue: "Active" })} /> : null}
{status.total > 0 ? (
<span
className={`project-status-dot ${status.needsAttention > 0 ? "attention" : ""}`}
aria-hidden="true"
/>
) : unreadResults > 0 ? (
<span
className={`project-status-badge ${status.failed > 0 ? "failed" : ""}`}
aria-hidden="true"
>
{unreadResults}
</span>
) : null}
</TooltipButton>
<span id={`${projectId}-path-description`} className="sr-only">
{entry.path}
{". "}
{t("project.reorder", { name: entry.name, defaultValue: "Reorder {{name}}" })}
{statusSummary ? `. ${t("nav.projectStatusLabel", { summary: statusSummary })}` : ""}
</span>
<div className="sidebar-menu-wrap">
<TooltipButton
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/features/app/useAppShellRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,36 @@ export function useAppShellRuntime() {
const offTodosChanged = api.onTodosChanged((snapshot) =>
useAppStore.getState().applyTodosChanged(snapshot),
);
const offScheduledChanged = api.onScheduledChanged((change) =>
useAppStore.getState().applyScheduledRunChanged(change),
);
// A reload misses runs that started before it, so the live ones are seeded
// from the task list plus one newest run per task (issue #1441).
void (async () => {
try {
const [{ tasks }, { runs }] = await Promise.all([
api.listScheduled(),
api.listScheduledRuns({ latestPerTask: true }),
]);
const byId = new Map(tasks.map((task) => [task.id, task]));
for (const run of runs) {
// A run with no conversation of its own could never be attributed to
// a project row, so it is not seeded at all.
if (run.status !== "running" || !run.sessionId) continue;
const task = byId.get(run.taskId);
useAppStore.getState().applyScheduledRunChanged({
runId: run.id,
sessionId: run.sessionId,
taskId: run.taskId,
...(task?.title ? { taskTitle: task.title } : {}),
...(task?.workspacePath ? { projectPath: task.workspacePath } : {}),
status: "running",
});
}
} catch {
// Additive surface: a failed seed simply leaves the row without runs.
}
})();
// Host-pushed toasts (plugin runtime etc.) are informational.
const offToast = api.onToast((message) => showToast(message));
const offNotificationSound = api.onNotificationSound(playNotificationChime);
Expand Down Expand Up @@ -823,6 +853,7 @@ export function useAppShellRuntime() {
offQueueChanged();
offPlansChanged();
offTodosChanged();
offScheduledChanged();
offToast();
offNotificationSound();
offInsecureEndpoint();
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type {
ProjectMemory,
ProjectMemoryEntry,
ProjectWorkspace,
ScheduledRunChange,
ScheduledTask,
ProviderCreateInput,
ProviderPublic,
Expand Down Expand Up @@ -1568,6 +1569,14 @@ export const api = {
listener(payload as SessionTodoSnapshot),
);
},
/** A scheduled run arriving or settling, for the project rows (issue #1441). */
onScheduledChanged: (listener: (change: ScheduledRunChange) => void) => {
if (!window.piDesktop?.on) return () => undefined;
return window.piDesktop.on(IPC.event.scheduledChanged, (payload) => {
const change = (payload as { change?: ScheduledRunChange } | null)?.change;
if (change) listener(change);
});
},
onOauthLogin: (listener: (event: OAuthLoginEvent) => void) => {
if (!window.piDesktop?.on) return () => undefined;
return window.piDesktop.on(IPC.event.providersOauth, (payload) =>
Expand Down
Loading
Loading