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 @@ -898,6 +898,44 @@ describe("CommandPalette", () => {
expect(screen.getByRole("button", { name: /Remove provider filter codex/i })).toBeTruthy();
});

it("files an attached shell under its settled chat for Work status search", async () => {
const parent = makeSession({
id: "session-settled-parent",
title: "Settled parent chat",
toolType: "codex-chat",
status: "completed",
runtimeState: "idle",
endedAt: new Date().toISOString(),
exitCode: 0,
settledAt: new Date().toISOString(),
});
const child = makeSession({
id: "session-attached-shell",
title: "Attached shell",
toolType: "shell",
status: "running",
runtimeState: "running",
chatSessionId: parent.id,
settledAt: null,
});
seedThreads([parent, child]);

render(
<MemoryRouter>
<CommandPalette open onOpenChange={vi.fn()} />
</MemoryRouter>,
);

fireEvent.change(
screen.getByPlaceholderText("Search commands, projects, and threads…"),
{ target: { value: "status:settled attached" } },
);

expect(await screen.findByTestId("thread-status-session-attached-shell")).toBeTruthy();
expect(screen.queryByText("Settled parent chat")).toBeNull();
expect(document.querySelector('[data-thread-id="session-attached-shell"]')).toBeTruthy();
});

it("shows lifecycle actions on the highlighted Work result and targets that session", async () => {
const settle = vi.fn(async () => {});
globalThis.window.ade.sessions = { settle } as any;
Expand Down
47 changes: 41 additions & 6 deletions apps/desktop/src/renderer/components/app/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ import {
WORK_SEARCH_FILTER_KEYS,
type WorkSearchFilterKey,
} from "../../../shared/workSearch";
import { sessionFilingBucket } from "../../lib/terminalAttention";
import {
effectiveSessionFilingBuckets,
type SessionFilingBucket,
} from "../../lib/terminalAttention";
import { nextSnoozeDeadlineMs } from "../../lib/sessionSnooze";
import {
ENTITY_SECTION_PREVIEW,
SearchResultRow,
Expand Down Expand Up @@ -273,6 +277,7 @@ function saveLastBrowsePath(locationKey: string, path: string): void {
// Resolved project icons are stable for a given root path within a session, so
// cache them module-wide to avoid rescanning the disk on every re-highlight.
const PROJECT_ICON_CACHE_MAX = 64;
const PALETTE_SNOOZE_TICK_MAX_DELAY_MS = 10 * 60 * 1000;
const PROJECT_ICON_CACHE = new Map<string, ProjectIcon>();

function rememberProjectIcon(rootPath: string, icon: ProjectIcon): void {
Expand Down Expand Up @@ -925,10 +930,38 @@ export function CommandPalette({
foreignMachines,
activeMachine,
);
const threadSessionsForFiling = useMemo(
() => threadIndex.map((entry) => entry.session),
[threadIndex],
);
const [filingEpoch, setFilingEpoch] = useState(0);
const filingNowMs = useMemo(() => {
// The epoch is a deadline tick; reading it makes this clock refresh when a
// snooze expires even if the indexed session objects retain their identity.
void filingEpoch;
return Date.now();
}, [filingEpoch]);
useEffect(() => {
if (!open || mode !== "default") return undefined;
const deadlineMs = nextSnoozeDeadlineMs(threadSessionsForFiling);
if (deadlineMs == null) return undefined;
const delay = Math.min(
Math.max(deadlineMs - Date.now(), 250),
PALETTE_SNOOZE_TICK_MAX_DELAY_MS,
);
const timer = window.setTimeout(() => setFilingEpoch((value) => value + 1), delay);
return () => window.clearTimeout(timer);
}, [filingEpoch, mode, open, threadSessionsForFiling]);
const effectiveFilingBuckets = useMemo<ReadonlyMap<string, SessionFilingBucket>>(
() => effectiveSessionFilingBuckets(threadSessionsForFiling, filingNowMs),
[filingNowMs, threadSessionsForFiling],
);
const threadMatches = useMemo(
() =>
open && mode === "default" ? rankThreads(threadIndex, trimmedQuery) : [],
[mode, open, threadIndex, trimmedQuery],
open && mode === "default"
? rankThreads(threadIndex, trimmedQuery, effectiveFilingBuckets)
: [],
[effectiveFilingBuckets, mode, open, threadIndex, trimmedQuery],
);

const workFacetOptions = useMemo<
Expand All @@ -944,7 +977,8 @@ export function CommandPalette({
for (const entry of threadIndex) {
if (entry.laneName) values.lane.add(entry.laneName);
if (entry.provider) values.provider.add(entry.provider);
values.status.add(sessionFilingBucket(entry.session));
const filingBucket = effectiveFilingBuckets.get(entry.session.id);
if (filingBucket) values.status.add(filingBucket);
values.type.add(
isChatToolType(entry.session.toolType) ? "chat" : "terminal",
);
Expand All @@ -961,7 +995,7 @@ export function CommandPalette({
options[key] = [...values[key]].sort((a, b) => a.localeCompare(b));
}
return options;
}, [threadIndex]);
}, [effectiveFilingBuckets, threadIndex]);

const {
loading: searchLoading,
Expand All @@ -979,8 +1013,9 @@ export function CommandPalette({
sessionResults,
threadIndex,
threadMatches,
effectiveFilingBuckets,
}),
[parsedWorkQuery, sessionResults, threadIndex, threadMatches],
[effectiveFilingBuckets, parsedWorkQuery, sessionResults, threadIndex, threadMatches],
);

const visibleWorkResults = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ import {
} from "../../lib/sessionSnooze";
import {
canonicalInputFromSummary,
effectiveSessionFilingBuckets,
sessionFilingBucket,
sessionIsMidFlight,
sessionCanonicalUiState,
sessionStatusDisplay,
type SessionFilingBucket,
} from "../../lib/terminalAttention";
import { cn } from "../ui/cn";
import { highlightRanges, highlightTitle } from "./commandPaletteSearch";
Expand Down Expand Up @@ -250,8 +252,10 @@ export function buildThreadIndex(
export function matchesThreadWorkFacets(
entry: ThreadIndexEntry,
parsed: ParsedWorkSearch,
effectiveFilingBuckets?: ReadonlyMap<string, SessionFilingBucket>,
): boolean {
const filingBucket = sessionFilingBucket(entry.session);
const filingBucket = effectiveFilingBuckets?.get(entry.session.id)
?? sessionFilingBucket(entry.session);
if (!matchesWorkSearchFilters(parsed.filters, {
lane: [entry.laneName],
provider: [entry.provider, entry.toolTypeLower],
Expand Down Expand Up @@ -372,6 +376,8 @@ export type ThreadRowAction = "new-chat" | "rename" | "settle" | "snooze";
export function rankThreads(
index: readonly ThreadIndexEntry[],
query: string,
effectiveFilingBuckets: ReadonlyMap<string, SessionFilingBucket> =
effectiveSessionFilingBuckets(index.map((entry) => entry.session)),
): ThreadMatch[] {
const parsed = parseWorkSearchQuery(query);
if (
Expand All @@ -383,7 +389,7 @@ export function rankThreads(
}
const matches: ThreadMatch[] = [];
for (const entry of index) {
if (!matchesThreadWorkFacets(entry, parsed)) continue;
if (!matchesThreadWorkFacets(entry, parsed, effectiveFilingBuckets)) continue;
let total = 0;
let matchedEveryTerm = true;
const matchFields: ThreadMatchField[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ThreadMatch,
type ThreadRowAction,
} from "./commandPaletteThreads";
import type { SessionFilingBucket } from "../../lib/terminalAttention";
import {
projectStateKeyForBinding,
type WorkProjectViewState,
Expand All @@ -21,6 +22,7 @@ import { invalidateSessionListCache } from "../../lib/sessionListCache";
import { isSessionSnoozed } from "../../lib/sessionSnooze";
import {
canonicalInputFromSummary,
effectiveSessionFilingBuckets,
sessionCanonicalUiState,
} from "../../lib/terminalAttention";
import {
Expand Down Expand Up @@ -263,11 +265,13 @@ export function buildWorkResults({
sessionResults,
threadIndex,
threadMatches,
effectiveFilingBuckets,
}: {
parsedWorkQuery: ParsedWorkSearch;
sessionResults: readonly SearchResultItem[];
threadIndex: readonly ThreadIndexEntry[];
threadMatches: readonly ThreadMatch[];
effectiveFilingBuckets?: ReadonlyMap<string, SessionFilingBucket>;
}): PaletteWorkResult[] {
const contentBySessionId = new Map<string, SearchResultItem>();
for (const item of sessionResults) {
Expand All @@ -279,6 +283,8 @@ export function buildWorkResults({
const localEntriesById = new Map(
threadIndex.map((entry) => [entry.session.id, entry] as const),
);
const filingBuckets = effectiveFilingBuckets
?? effectiveSessionFilingBuckets(threadIndex.map((entry) => entry.session));
const matchedSessionIds = new Set<string>();
const merged: PaletteWorkResult[] = threadMatches.map((match) => {
const sessionId = match.entry.session.id;
Expand All @@ -300,7 +306,7 @@ export function buildWorkResults({
if (item.sessionId) {
const localEntry = localEntriesById.get(item.sessionId);
if (localEntry) {
if (!matchesThreadWorkFacets(localEntry, parsedWorkQuery)) continue;
if (!matchesThreadWorkFacets(localEntry, parsedWorkQuery, filingBuckets)) continue;
matchedSessionIds.add(item.sessionId);
merged.push({
type: "thread",
Expand Down
152 changes: 151 additions & 1 deletion apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
PrSummary,
TerminalSessionChangedEvent,
TerminalSessionDetail,
TerminalSessionSummary,
} from "../../../shared/types";
import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry";
import { invalidateAgentChatSessionListCache } from "../../lib/agentChatSessionListCache";
Expand Down Expand Up @@ -8450,11 +8451,160 @@ describe("AgentChatPane submit recovery", () => {
renderPane(session);

expect(await screen.findByText("Check PR CI")).toBeTruthy();
expect(screen.queryByText(/While you were away:/)).toBeNull();
expect(screen.queryByTestId("chat-away-digest")).toBeNull();
expect(window.localStorage.getItem(`ade.chat.lastViewed.v1:${session.sessionId}`))
.toBe(String(openedAtMs));
});

it("shows unattended scheduled wakes as one compact review card", async () => {
const openedAtMs = Date.parse("2026-07-10T12:00:00.000Z");
vi.spyOn(Date, "now").mockReturnValue(openedAtMs);
const session = buildSession("session-1", { title: "Scheduled work" });
window.localStorage.setItem(
`ade.chat.lastViewed.v1:${session.sessionId}`,
String(Date.parse("2026-07-10T10:00:00.000Z")),
);
const longOutcome = "Deployment completed after a very long diagnostic summary that should remain in the transcript instead of being crammed into the notice.";
installAdeMocks({
sessions: [session],
eventHistory: {
sessionId: session.sessionId,
truncated: false,
sessionFound: true,
events: [
{
sessionId: session.sessionId,
timestamp: "2026-07-10T10:30:00.000Z",
sequence: 1,
event: {
type: "user_message",
text: "Check CI",
deliveryState: "delivered",
turnId: "turn-wake-1",
metadata: {
scheduledWake: {
scheduleId: "wake-1",
kind: "wakeup",
firedAt: "2026-07-10T10:30:00.000Z",
reason: "Check CI",
},
},
},
},
{
sessionId: session.sessionId,
timestamp: "2026-07-10T10:31:00.000Z",
sequence: 2,
event: { type: "text", text: longOutcome, turnId: "turn-wake-1" },
},
{
sessionId: session.sessionId,
timestamp: "2026-07-10T11:30:00.000Z",
sequence: 3,
event: {
type: "user_message",
text: "Check deployment",
deliveryState: "delivered",
turnId: "turn-wake-2",
metadata: {
scheduledWake: {
scheduleId: "wake-2",
kind: "wakeup",
firedAt: "2026-07-10T11:30:00.000Z",
reason: "Check deployment",
},
},
},
},
],
},
});

renderPane(session);

const digest = await screen.findByTestId("chat-away-digest");
expect(digest.className).toContain("rounded-2xl");
expect(digest.className).not.toContain("w-full");
expect(within(digest).getByText("While you were away")).toBeTruthy();
expect(within(digest).getByText("2 scheduled wakeups ran")).toBeTruthy();
expect(within(digest).queryByText(longOutcome)).toBeNull();
expect(screen.getByTestId("chat-composer-notice-overlay").contains(digest)).toBe(true);

const review = within(digest).getByRole("button", { name: "Review" });
expect(review.getAttribute("title")).toBe("First wakeup: Check CI");
expect(within(digest).getAllByRole("button")).toHaveLength(2);

fireEvent.click(within(digest).getByRole("button", { name: "Dismiss while-you-were-away summary" }));
await waitFor(() => expect(screen.queryByTestId("chat-away-digest")).toBeNull());
});

it("does not reserve an empty notice row above a live app-panel composer", async () => {
const session = buildSession("session-1", { title: "Live app-control chat" });
writeChatCompanionUiState(session.sessionId, {
...DEFAULT_CHAT_COMPANION_UI_STATE,
appControlOpen: true,
});
installAdeMocks({ sessions: [session] });

const { container } = renderPane(session);

expect(await screen.findByPlaceholderText("Type to vibecode...")).toBeTruthy();
expect(screen.queryByTestId("chat-lifecycle-banner")).toBeNull();
expect(screen.queryByTestId("chat-app-panel-notice-stack")).toBeNull();
const emptyPaddedNoticeRows = [...container.querySelectorAll("div")].filter((element) =>
element.childElementCount === 0
&& element.classList.contains("items-center")
&& element.classList.contains("py-1.5"),
);
expect(emptyPaddedNoticeRows).toHaveLength(0);
});

it("centers a lifecycle pill above an app-panel composer", async () => {
const session = buildSession("session-1", { title: "Settled app-control chat" });
writeChatCompanionUiState(session.sessionId, {
...DEFAULT_CHAT_COMPANION_UI_STATE,
appControlOpen: true,
});
const projectRoot = "/tmp/project-under-test";
const settledSession: TerminalSessionSummary = {
id: session.sessionId,
laneId: session.laneId,
laneName: "Lane 1",
ptyId: null,
tracked: true,
pinned: false,
goal: null,
toolType: "codex-chat",
title: session.title ?? "Settled app-control chat",
status: "completed",
startedAt: "2026-07-10T10:00:00.000Z",
endedAt: "2026-07-10T11:00:00.000Z",
exitCode: 0,
transcriptPath: "",
headShaStart: null,
headShaEnd: null,
lastOutputPreview: null,
summary: null,
runtimeState: "exited",
resumeCommand: null,
settledAt: "2026-07-10T11:01:00.000Z",
};
useAppStore.setState({
project: { rootPath: projectRoot } as never,
projectBinding: null,
sessionsCacheByProject: { [projectRoot]: [settledSession] },
});
installAdeMocks({ sessions: [session] });

renderPane(session);

const pill = await screen.findByTestId("chat-lifecycle-banner");
expect(pill.className).toContain("flex");
expect(pill.className).toContain("w-fit");
expect(pill.className).toContain("mx-auto");
expect(pill.className).not.toContain("inline-flex");
});

it("validates empty legacy event-history snapshots before treating them as loaded", async () => {
const session = buildSession("session-1", { title: "Possibly foreign chat" });
installAdeMocks({
Expand Down
Loading
Loading