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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export type ListedControlSession = OpenworkSessionActivityInventory & {
workspace: string;
updatedAt: number;
pinned: boolean;
/** On the sidebar's Settled row (OpenCode v2 workspaces only); false elsewhere. */
settled: boolean;
/** Live activity, the same source as the sidebar indicator. */
status: SessionActivityStatus;
/** Model and reasoning effort the session is bound to; null before any model is bound. */
Expand All @@ -54,6 +56,7 @@ export type ListControlSessionsState = {
modelCatalogByWorkspaceId?: Record<string, readonly OpenworkCatalogModel[]>;
statusFor: (workspaceId: string, sessionId: string) => SessionActivityStatus;
attentionFor?: (workspaceId: string, sessionId: string) => SessionAttention | undefined;
settledFor?: (workspaceId: string, session: ControlSessionLike & { id: string }) => boolean;
};

/** Anything but a finished or failed turn still needs Stop before archive. */
Expand Down Expand Up @@ -119,6 +122,7 @@ export function listControlSessions(args: unknown, state: ListControlSessionsSta
workspace: controlWorkspaceLabel(workspace),
updatedAt: session.time?.updated ?? session.time?.created ?? 0,
pinned: state.pinnedIds.includes(sessionId),
settled: state.settledFor?.(workspace.id, { ...session, id: sessionId }) ?? false,
status: activity.status,
working: activity.working,
descendantActivity: activity.descendantActivity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { useCheckDesktopRestriction } from "../../cloud/desktop-config-provider"
import { useDenAuth } from "../../cloud/den-auth-provider";
import { filterEntitledModelOptions } from "../../connections/provider-auth/provider-policy";
import { filterCloudManagedModelOptions } from "../../connections/provider-auth/assigned-model-options";
import { getDisplaySessionTitle } from "../../../../app/lib/session-title";
import { useSessionManagementStore } from "../sidebar/session-management-store";
import { readSessionSettled, sessionActivityAt, settleSession, unsettleSession } from "../sidebar/session-settle";
import { isActiveWorkSessionStatus, isNeedsAttentionSessionStatus } from "../sidebar/utils";
import { useSessionModelStore } from "../surface/session-model-store";
import type { ArchiveSessionOptions, ArchiveSessionOutcome } from "../sidebar/use-session-archive";
import { useSessionActivityStore } from "../status/session-activity-store";
Expand All @@ -36,6 +39,10 @@ type UseSessionControlActionsInput = {
openworkClient: OpenworkServerClient | null;
opencodeClient: ReturnType<typeof createClient> | null;
archiveDisabledReason?: string;
/** Workspaces where the sidebar offers Settle (the rollout flag and OpenCode v2). */
settleWorkspaceIds?: ReadonlySet<string>;
/** The sidebar's session status, including background work and pending requests. */
sessionStatusById?: Readonly<Record<string, string>>;
endpointForWorkspace: (workspace: SessionControlWorkspace | null | undefined) => ResolvedWorkspaceEndpoint | null;
navigateToSession: (sessionId: string) => void;
navigateToSessionRoot: () => void;
Expand All @@ -48,6 +55,8 @@ type UseSessionControlActionsInput = {

const ARCHIVE_TARGET_WORKING_HINT = "This session is still working. If the user wants it closed, ask them to stop it in the app (or wait until session.list_sessions reports working=false), then archive. If not, leave it running.";
const SELF_ARCHIVE_WHILE_WORKING_HINT = "A working session cannot archive itself. Finish the turn so your conclusions can be reviewed; the reviewer archives.";
const SETTLE_TARGET_WORKING_HINT = "Working sessions stay in view. Settle it once session.list_sessions reports working=false, or leave it.";
const SETTLE_TARGET_WAITING_HINT = "The session is waiting on the person. Answer its pending request (session.question.list / session.question.answer) only with the person's own answer, or ask them; then settle it.";

function findSessionWorkspace(
workspaces: SessionControlWorkspace[],
Expand Down Expand Up @@ -83,6 +92,8 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) {
openworkClient,
opencodeClient,
archiveDisabledReason,
settleWorkspaceIds,
sessionStatusById,
refreshRouteState,
reloadWorkspaceSessions,
selectedSessionId,
Expand Down Expand Up @@ -147,7 +158,7 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) {
const listSessionsControlAction = useMemo<OpenworkControlAction>(() => ({
id: "session.list_sessions",
label: "List available sessions",
description: "Return every loaded session across workspaces (pinned first, then newest). Entries include `pinned`, `status` (idle, thinking, responding, waiting, compacting, error), `working` (own work or known busy/waiting descendants), `descendantActivity` ({ busy, waiting, unknown } counts), `inventoryComplete` (false when referenced descendant activity is unreadable; unknown alone does not imply working) and `model` ({ providerId, modelId, variant, displayName?, providerName? }: the model and reasoning effort the session is bound to, null before any model is bound). Check `working` before session.archive. Pass `limit` to cap the count or `workspaceId` to narrow to one workspace.",
description: "Return every loaded session across workspaces (pinned first, then newest). Entries include `pinned`, `settled` (on the sidebar's Settled row; see session.settle), `status` (idle, thinking, responding, waiting, compacting, error), `working` (own work or known busy/waiting descendants), `descendantActivity` ({ busy, waiting, unknown } counts), `inventoryComplete` (false when referenced descendant activity is unreadable; unknown alone does not imply working) and `model` ({ providerId, modelId, variant, displayName?, providerName? }: the model and reasoning effort the session is bound to, null before any model is bound). Check `working` before session.archive. Pass `limit` to cap the count or `workspaceId` to narrow to one workspace.",
kind: "query",
effects: { data: "read", ui: "none", external: false },
sideEffect: "none",
Expand All @@ -170,6 +181,8 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) {
pinnedIds,
modelCatalogByWorkspaceId,
statusFor: activity.getStatus,
settledFor: (workspaceId, session) => Boolean(settleWorkspaceIds?.has(workspaceId))
&& readSessionSettled(session, sessionStatusById?.[session.id] ?? activity.getStatus(workspaceId, session.id)),
attentionFor: (workspaceId, sessionId) => {
let attention = attentionByWorkspaceId.get(workspaceId);
if (!attention) {
Expand All @@ -187,7 +200,7 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) {
},
});
},
}), [endpointForWorkspace, pinnedIds, sessionsByWorkspaceId, workspaceModels, workspaces]);
}), [endpointForWorkspace, pinnedIds, sessionStatusById, sessionsByWorkspaceId, settleWorkspaceIds, workspaceModels, workspaces]);
useControlAction(listSessionsControlAction);

const openSessionControlAction = useMemo<OpenworkControlAction>(() => ({
Expand Down Expand Up @@ -433,6 +446,49 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) {
}), [sessionsByWorkspaceId, workspaces]);
useControlAction(pinControlAction);

const settleControlAction = useMemo<OpenworkControlAction>(() => ({
id: "session.settle",
label: "Settle or un-settle a session",
description: "Settle a finished session: it leaves the active sidebar list for its workspace's collapsed Settled row and comes back on its own with any new activity. Pass settled=false to bring it back now. Only idle top-level sessions in OpenCode v2 workspaces settle; a session that is working (code target_working) or waiting on the person (code target_waiting) is not settled. Settling also unpins it and clears unread. Settled state is kept on this device only. Read `settled` in session.list_sessions.",
sideEffect: "mutation",
requiresArgs: true,
args: [
{ name: "sessionId", type: "string", required: true, description: "Session ID from session.list_sessions." },
{ name: "settled", type: "boolean", required: true, description: "true to settle, false to bring it back." },
],
disabled: !settleWorkspaceIds?.size,
execute: (args) => {
const sessionId = stringArg(args, "sessionId");
const settled = objectArgs(args).settled;
if (!sessionId) return { ok: false, error: "sessionId is required" };
if (typeof settled !== "boolean") return { ok: false, error: "settled must be true or false" };
const workspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId);
const found = workspace && sessionsByWorkspaceId[workspace.id]?.find((session) => session.id === sessionId);
if (!workspace || !found) return { ok: false, error: "Session was not found in the current session list" };
if (!settleWorkspaceIds?.has(workspace.id)) return { ok: false, code: "unavailable", sessionId, error: "Settle is available only in OpenCode v2 workspaces." };
if ((found.time?.archived ?? 0) > 0) return { ok: false, sessionId, error: "Archived sessions cannot be settled. Unarchive the session first." };
if (found.parentID?.trim()) return { ok: false, sessionId, error: "Sub-agent sessions are not listed on their own; settle the parent session." };
const session = { ...found, id: sessionId };
const title = getDisplaySessionTitle(session.title ?? "");
const status = sessionStatusById?.[sessionId] ?? useSessionActivityStore.getState().getStatus(workspace.id, sessionId);
const current = readSessionSettled(session, status);
if (current === settled) return { ok: true, sessionId, settled, unchanged: true };
if (settled) {
if (isActiveWorkSessionStatus(status)) {
return { ok: false, code: "target_working", sessionId, title, error: `"${title}" is still working; it was not settled.`, hint: SETTLE_TARGET_WORKING_HINT };
}
if (isNeedsAttentionSessionStatus(status)) {
return { ok: false, code: "target_waiting", sessionId, title, error: `"${title}" is waiting on the person; it was not settled.`, hint: SETTLE_TARGET_WAITING_HINT };
}
settleSession(sessionId, title, sessionActivityAt(session));
} else {
unsettleSession(sessionId, title);
}
return { ok: true, sessionId, settled: readSessionSettled(session, status) };
},
}), [sessionStatusById, sessionsByWorkspaceId, settleWorkspaceIds, workspaces]);
useControlAction(settleControlAction);

const archiveControlAction = useMemo<OpenworkControlAction>(() => ({
id: "session.archive",
label: "Archive or unarchive a session",
Expand Down
20 changes: 19 additions & 1 deletion apps/app/src/react-app/domains/session/sidebar/session-settle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,25 @@ export function settleWorkspaceIds<T extends { id: string }>(
.map((workspace) => workspace.id));
}

export type SettledSessionContext = Omit<SessionSettleInput, "activityAt" | "entry" | "status" | "pinned" | "unread"> & {
/**
* Whether a session is settled right now, read outside React for agent
* commands. Same rules as the sidebar row; the caller applies the v2 gate.
*/
export function readSessionSettled(session: SessionTimes, status: string | undefined, now = Date.now()): boolean {
if ((session.time?.archived ?? 0) > 0 || session.parentID?.trim()) return false;
const state = useSessionManagementStore.getState();
return isSessionSettled({
activityAt: sessionActivityAt(session),
entry: state.settledById[session.id],
status,
pinned: state.pinnedIds.includes(session.id),
unread: state.unreadIds.includes(session.id),
autoSettleDays: state.autoSettleDays,
now,
});
}

export type SettledSessionContext =Omit<SessionSettleInput, "activityAt" | "entry" | "status" | "pinned" | "unread"> & {
settledById: Record<string, SessionSettleEntry>;
statusById: Record<string, string> | undefined;
pinnedIds: ReadonlySet<string>;
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/react-app/shell/session-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3094,6 +3094,8 @@ export function SessionRoute() {
canCreateTask,
openworkClient: client,
opencodeClient,
settleWorkspaceIds: settleWorkspaceIdSet,
sessionStatusById: sidebarSessionStatusById,
endpointForWorkspace,
navigateToSession: navigateToSessionForControl,
navigateToSessionRoot: navigateToSessionRootForControl,
Expand Down
45 changes: 41 additions & 4 deletions apps/app/tests/session-model-reassignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ const cleanups: Array<() => void | Promise<void>> = [];
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
afterAll(async () => { Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); await GlobalRegistrator.unregister(); });

async function mount(options: { archived?: boolean; busy?: boolean; v1?: boolean; deny?: boolean; wrongReadback?: boolean; disconnected?: boolean } = {}) {
async function mount(options: { archived?: boolean; busy?: boolean; v1?: boolean; deny?: boolean; wrongReadback?: boolean; disconnected?: boolean; settleIn?: string[]; status?: string } = {}) {
useSessionModelStore.getState().setModel("ses_target", { providerID: "rebind", modelID: "model-a" });
useSessionManagementStore.setState({ pinnedIds: [] });
useSessionManagementStore.setState({ pinnedIds: [], unreadIds: [], settledById: {} });
const writes: unknown[] = [];
const renames: Array<{ path: string; method: string; authorization: string | null; body: unknown }> = [];
const reloadedWorkspaces: string[] = [];
Expand Down Expand Up @@ -71,7 +71,8 @@ async function mount(options: { archived?: boolean; busy?: boolean; v1?: boolean
} : null;
function Register() {
useSessionControlActions({ workspaces, selectedWorkspaceId: "one", selectedWorkspaceRoot: "/tmp/one", selectedSessionId: null,
sessionsByWorkspaceId: { two: [{ id: "ses_target", time: { archived: options.archived ? 123 : 0 } }] }, canCreateTask: false, openworkClient: client, opencodeClient: null, endpointForWorkspace,
sessionsByWorkspaceId: { two: [{ id: "ses_target", time: { updated: 1_700_000_000, archived: options.archived ? 123 : 0 } }] }, canCreateTask: false,
settleWorkspaceIds: new Set(options.settleIn ?? ["two"]), sessionStatusById: options.status ? { ses_target: options.status } : undefined, openworkClient: client, opencodeClient: null, endpointForWorkspace,
navigateToSession: () => { throw new Error("Must not navigate"); }, navigateToSessionRoot: () => { throw new Error("Must not navigate"); },
createTaskInWorkspace: () => null, openModelPicker: () => { throw new Error("Must not open picker"); }, refreshRouteState: () => {},
reloadWorkspaceSessions: async (workspaceId) => { reloadedWorkspaces.push(workspaceId); }, archiveSession: async () => ({ kind: "done" }),
Expand All @@ -89,7 +90,12 @@ async function mount(options: { archived?: boolean; busy?: boolean; v1?: boolean
await act(async () => { response = await api.command({ id, args, origin: { sessionId: "ses_requester" } }); });
return response;
};
return { writes, renames, reloadedWorkspaces, command, run: (extra: Record<string, unknown> = {}) => command("session.set_model", {
const query = async (id: string, args: Record<string, unknown>) => {
let response: Awaited<ReturnType<typeof api.query>> | undefined;
await act(async () => { response = await api.query({ id, args }); });
return response;
};
return { writes, renames, reloadedWorkspaces, command, query, run: (extra: Record<string, unknown> = {}) => command("session.set_model", {
sessionId: "ses_target", providerId: "rebind", modelId: "model-b", ...extra,
}) };
}
Expand Down Expand Up @@ -181,3 +187,34 @@ test("pin rejects missing and archived targets but stale pins can still be remov
expect(await fixture.command("session.pin", { sessionId: "ses_target" })).toMatchObject({ ok: true, result: { pinned: false } });
expect(useSessionManagementStore.getState().pinnedIds).toEqual([]);
});

test("settle moves an idle session to Settled and un-settle brings it back, without engine writes", async () => {
const fixture = await mount();
await act(async () => { useSessionManagementStore.setState({ pinnedIds: ["ses_target"], unreadIds: ["ses_target"] }); });
expect(await fixture.command("session.settle", { sessionId: "ses_target", settled: true })).toMatchObject({ ok: true, result: { ok: true, settled: true } });
expect(useSessionManagementStore.getState()).toMatchObject({ pinnedIds: [], unreadIds: [] });
expect(await fixture.query("session.list_sessions", {})).toMatchObject({ ok: true, result: [{ sessionId: "ses_target", settled: true, pinned: false }] });
expect(await fixture.command("session.settle", { sessionId: "ses_target", settled: true })).toMatchObject({ ok: true, result: { settled: true, unchanged: true } });
expect(await fixture.command("session.settle", { sessionId: "ses_target", settled: false })).toMatchObject({ ok: true, result: { ok: true, settled: false } });
expect(await fixture.query("session.list_sessions", {})).toMatchObject({ ok: true, result: [{ sessionId: "ses_target", settled: false }] });
expect(fixture.writes).toEqual([]);
expect(fixture.renames).toEqual([]);
});

for (const [options, code] of [
[{ status: "waiting" }, "target_waiting"], [{ status: "thinking" }, "target_working"], [{ settleIn: ["one"] }, "unavailable"],
] satisfies Array<[{ status?: string; settleIn?: string[] }, string]>) {
test(`settle refuses ${code} and leaves the session in view`, async () => {
const fixture = await mount(options);
expect(await fixture.command("session.settle", { sessionId: "ses_target", settled: true })).toMatchObject({ ok: false, code });
expect(useSessionManagementStore.getState().settledById).toEqual({});
});
}

test("settle rejects archived, missing and malformed targets", async () => {
const fixture = await mount({ archived: true });
expect(await fixture.command("session.settle", { sessionId: "ses_target", settled: true })).toMatchObject({ ok: false, error: expect.stringContaining("Unarchive") });
expect(await fixture.command("session.settle", { sessionId: "ses_missing", settled: true })).toMatchObject({ ok: false, error: expect.stringContaining("not found") });
expect(await fixture.command("session.settle", { sessionId: "ses_target", settled: "yes" })).toMatchObject({ ok: false });
expect(useSessionManagementStore.getState().settledById).toEqual({});
});
Loading
Loading