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
14 changes: 14 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1750,6 +1750,7 @@ describe("createSyncRemoteCommandService", () => {
"chat.prepareCrossMachineHandoff",
"chat.validateCrossMachineSource",
"chat.preflightCrossMachineDestination",
"chat.fastForwardCrossMachineHandoffLane",
"chat.acceptCrossMachineHandoff",
"chat.markCrossMachineHandoff",
"chat.getContextUsage",
Expand Down Expand Up @@ -1866,6 +1867,10 @@ describe("createSyncRemoteCommandService", () => {
blockingErrors: [],
warnings: [],
});
const fastForwardCrossMachineHandoffLane = vi.fn().mockResolvedValue({
ok: true,
head: capsule.source.headSha,
});
const acceptCrossMachineHandoff = vi.fn().mockResolvedValue({
handoffId: capsule.handoffId,
laneId: "lane-2",
Expand All @@ -1879,6 +1884,7 @@ describe("createSyncRemoteCommandService", () => {
prepareCrossMachineHandoff,
validateCrossMachineSource,
preflightCrossMachineDestination,
fastForwardCrossMachineHandoffLane,
acceptCrossMachineHandoff,
markCrossMachineHandoff,
},
Expand Down Expand Up @@ -1918,6 +1924,10 @@ describe("createSyncRemoteCommandService", () => {
sourceBranchRef: capsule.source.branchRef,
sourceHeadSha: capsule.source.headSha,
}))).resolves.toMatchObject({ providerAuthorized: true, modelAvailable: true });
await expect(service.execute(makePayload("chat.fastForwardCrossMachineHandoffLane", {
laneId: " lane-2 ",
expectedHead: ` ${capsule.source.headSha} `,
}))).resolves.toEqual({ ok: true, head: capsule.source.headSha });
await expect(service.execute(makePayload("chat.acceptCrossMachineHandoff", {
capsule,
capsuleFingerprint: "fingerprint-1",
Expand All @@ -1936,6 +1946,10 @@ describe("createSyncRemoteCommandService", () => {
sourceBranchRef: capsule.source.branchRef,
sourceHeadSha: capsule.source.headSha,
});
expect(fastForwardCrossMachineHandoffLane).toHaveBeenCalledWith({
laneId: "lane-2",
expectedHead: capsule.source.headSha,
});
expect(acceptCrossMachineHandoff).toHaveBeenCalledWith({
capsule,
capsuleFingerprint: "fingerprint-1",
Expand Down
19 changes: 19 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,21 @@ function parseCrossMachineDestinationPreflightArgs(
};
}

function parseFastForwardCrossMachineHandoffLaneArgs(
value: Record<string, unknown>,
): { laneId: string; expectedHead: string } {
return {
laneId: requireString(
value.laneId,
"chat.fastForwardCrossMachineHandoffLane requires laneId.",
),
expectedHead: requireString(
value.expectedHead,
"chat.fastForwardCrossMachineHandoffLane requires expectedHead.",
),
};
}

function parsePrepareCrossMachineHandoffArgs(
value: Record<string, unknown>,
): AgentChatPrepareCrossMachineHandoffArgs {
Expand Down Expand Up @@ -4116,6 +4131,10 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio
requireService(args.agentChatService, "Agent chat service not available.").preflightCrossMachineDestination(
parseCrossMachineDestinationPreflightArgs(payload),
));
register("chat.fastForwardCrossMachineHandoffLane", { viewerAllowed: true, queueable: false }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").fastForwardCrossMachineHandoffLane(
parseFastForwardCrossMachineHandoffLaneArgs(payload),
));
register("chat.acceptCrossMachineHandoff", { viewerAllowed: true, queueable: false }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").acceptCrossMachineHandoff(
parseAcceptCrossMachineHandoffArgs(payload),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import type { ExternalSessionSummary } from "../../../../desktop/src/shared/types/externalSessions";
import {
clampExternalSessionBrowserContent,
externalSessionAnchors,
externalSessionBrowserActions,
externalSessionRowTitle,
normalizeExternalSessionListResult,
visibleExternalSessions,
} from "../externalSessionBrowser";
Expand Down Expand Up @@ -51,6 +53,60 @@ describe("externalSessionBrowser helpers", () => {
.toEqual(["cursor", "newest"]);
});

it("searches the sampled conversation, not just the title", () => {
const rows = [
session({ id: "titled", title: "Release plan", updatedAt: 50 }),
session({
id: "sampled",
title: null,
preview: "look at the checkout flow",
updatedAt: 40,
messages: [
{ role: "user", text: "look at the checkout flow", at: 1 },
{ role: "assistant", text: "The regression is in the coupon validator.", at: 2 },
],
}),
];

expect(visibleExternalSessions(rows, "all", "coupon").map((row) => row.id)).toEqual(["sampled"]);
// Rows without a message sample still match on the legacy fields only.
expect(visibleExternalSessions(rows, "all", "release").map((row) => row.id)).toEqual(["titled"]);
});

it("names titleless rows by their opening prompt and never repeats it as an anchor", () => {
const titleless = session({
title: null,
preview: "look at\n the checkout flow",
messages: [
{ role: "user", text: "look at the checkout flow", at: 1 },
{ role: "assistant", text: "Found it:\nthe coupon validator", at: 2 },
],
});

expect(externalSessionRowTitle(titleless)).toBe("look at the checkout flow");
expect(externalSessionAnchors(titleless)).toEqual({
started: null,
latest: "Found it: the coupon validator",
});

const titled = session({ title: "Checkout", preview: "look at the checkout flow" });
// Older hosts send no `messages`; the opening prompt is then the only anchor.
expect(externalSessionAnchors(titled)).toEqual({
started: "look at the checkout flow",
latest: null,
});

const singleTurn = session({
title: "Checkout",
preview: "look at the checkout flow",
messages: [{ role: "user", text: "look at the checkout flow", at: 1 }],
});
expect(externalSessionAnchors(singleTurn)).toEqual({
started: "look at the checkout flow",
latest: null,
});
});

it("clamps selection and action indexes after filter changes", () => {
const content: Extract<RightPaneContent, { kind: "external-session-browser" }> = {
kind: "external-session-browser",
Expand Down
30 changes: 26 additions & 4 deletions apps/ade-cli/src/tuiClient/components/RightPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import type { TuiChatSessionSummary } from "../adeApi";
import { theme } from "../theme";
import {
externalSessionActionKey,
externalSessionAnchors,
externalSessionBrowserActions,
externalSessionProviderLabel,
externalSessionRowTitle,
shortenCwd,
visibleExternalSessions,
} from "../externalSessionBrowser";
Expand Down Expand Up @@ -1608,7 +1610,8 @@ function ExternalSessionBrowserPane({
const absoluteIndex = windowStart + offset;
const selected = absoluteIndex === selectedIndex;
const brand = theme.provider(session.provider);
const title = session.title?.trim() || session.preview?.trim() || session.id;
const title = externalSessionRowTitle(session);
const anchors = externalSessionAnchors(session);
const cwd = shortenCwd(session.cwd, 4);
const messageCount = typeof session.messageCount === "number" && Number.isFinite(session.messageCount)
? `${compactNumber(session.messageCount)} prompt${session.messageCount === 1 ? "" : "s"}`
Expand All @@ -1634,9 +1637,28 @@ function ExternalSessionBrowserPane({
{` ${endTruncate(badges.join(" · "), Math.max(8, inner - 2))}`}
</Text>
) : null}
{selected && session.preview?.trim() ? (
<Text color={theme.color.t3} wrap="truncate-end">
{` ${endTruncate(session.preview.trim(), Math.max(8, inner - 2))}`}
{/*
Two anchors beat one snippet when you are picking a session out of a
list: the opening ask says which task this was, the last turn says how
far it got. Selected row only, one truncated line each, and each is
suppressed when the heading above already carries that text — so the
common titleless row still costs exactly the one line the old preview
did, spent on newer information.
*/}
{selected && anchors.started ? (
<Text wrap="truncate-end">
<Text color={theme.color.t5} dimColor>{" started "}</Text>
<Text color={theme.color.t4}>
{endTruncate(anchors.started, Math.max(8, inner - 12))}
</Text>
</Text>
) : null}
{selected && anchors.latest ? (
<Text wrap="truncate-end">
<Text color={theme.color.t5} dimColor>{" latest "}</Text>
<Text color={theme.color.t3}>
{endTruncate(anchors.latest, Math.max(8, inner - 12))}
</Text>
</Text>
) : null}
{selected && session.alreadyImported && session.importedSessionRef ? (
Expand Down
40 changes: 40 additions & 0 deletions apps/ade-cli/src/tuiClient/externalSessionBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,42 @@ export function externalSessionProviderLabel(provider: ExternalSessionProvider |
return provider === "all" ? "All" : PROVIDER_LABELS[provider] ?? provider;
}

/** Collapses provider text to one line so a TUI row can print it without wrapping. */
function collapseLine(value: string | null | undefined): string | null {
const collapsed = value?.replace(/\s+/gu, " ").trim();
return collapsed ? collapsed : null;
}

/**
* Row heading, mirroring the desktop browser's `sessionHeading`: a provider-persisted
* title when there is one, otherwise the opening prompt, otherwise the raw id. Most
* Claude CLI transcripts carry no title, so without the prompt fallback the row would
* name itself with a uuid.
*/
export function externalSessionRowTitle(session: ExternalSessionSummary): string {
return collapseLine(session.title) ?? collapseLine(session.preview) ?? session.id;
}

/**
* The two anchors the selected row prints: what the thread started as, and where it left
* off. Either can be absent — an older host sends no `messages` at all, and neither
* anchor may repeat text the row is already showing. The TUI gives each anchor a single
* truncated line, so a duplicate reads as a rendering bug rather than as emphasis.
*/
export function externalSessionAnchors(session: ExternalSessionSummary): {
started: string | null;
latest: string | null;
} {
const heading = externalSessionRowTitle(session);
const started = collapseLine(session.preview);
const messages = session.messages ?? [];
const latest = collapseLine(messages[messages.length - 1]?.text);
return {
started: started && started !== heading ? started : null,
latest: latest && latest !== heading && latest !== started ? latest : null,
};
}

export function normalizeExternalSessionListResult(result: unknown): ExternalSessionSummary[] {
if (Array.isArray(result)) return result as ExternalSessionSummary[];
if (!result || typeof result !== "object") return [];
Expand All @@ -97,6 +133,10 @@ export function visibleExternalSessions(
session.preview,
session.cwd,
session.id,
// Search the whole thread sample, not just the title: the words you remember
// from a conversation are usually in the conversation, and provider titles are
// frequently absent entirely. Matches the desktop browser's corpus.
...(session.messages ?? []).map((message) => message.text),
]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.some((value) => value.toLowerCase().includes(needle));
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"prepareCrossMachineHandoff",
"validateCrossMachineSource",
"preflightCrossMachineDestination",
"fastForwardCrossMachineHandoffLane",
"acceptCrossMachineHandoff",
"markCrossMachineHandoff",
"respondToInput",
Expand Down
Loading