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
7 changes: 6 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ import {
captureDailyUsageAnalytics,
completedDailyUsageAnalyticsTarget,
} from "../../desktop/src/main/services/analytics/dailyUsageAnalytics";
import { captureAgentTurnSettledAnalytics } from "../../desktop/src/main/services/analytics/agentTurnProductAnalytics";
import { captureAgentTurnSettledAnalytics, captureChatMentionsExpandedAnalytics } from "../../desktop/src/main/services/analytics/agentTurnProductAnalytics";
import { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService";
import { createReviewService } from "../../desktop/src/main/services/review/reviewService";
import { createProcessRegistryService } from "../../desktop/src/main/services/runtime/processRegistryService";
Expand Down Expand Up @@ -1229,6 +1229,11 @@ export async function createAdeRuntime(args: {
projectId,
event,
}),
onChatMentionsExpanded: (event) => captureChatMentionsExpandedAnalytics({
analytics: productAnalyticsService,
projectId,
sessionId: event.sessionId,
}),
onSessionEnded: (event) => {
pushEvent("runtime", { type: "agent_chat_session_ended", ...event });
},
Expand Down
48 changes: 30 additions & 18 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2478,9 +2478,9 @@ function segmentPromptLineText(
text: string,
rowStart: number,
tokens: PromptRenderTokenRange[],
): Array<{ text: string; kind: "plain" | "file" | "command" | "link" }> {
): Array<{ text: string; kind: "plain" | "file" | "command" | "mention" | "link" }> {
if (!tokens.length || !text) return text ? [{ text, kind: "plain" }] : [];
const segments: Array<{ text: string; kind: "plain" | "file" | "command" | "link" }> = [];
const segments: Array<{ text: string; kind: "plain" | "file" | "command" | "mention" | "link" }> = [];
let pos = 0;
for (const token of tokens) {
const start = Math.max(0, token.start - rowStart);
Expand All @@ -2495,6 +2495,10 @@ function segmentPromptLineText(
}

export const MENTION_REMOTE_DEBOUNCE_MS = 160;
/** Rows the mention palette renders at most. */
export const MENTION_MAX_ROWS = 10;
/** File rows requested from quick-open, and reserved when browsing on a bare `@`. */
export const MENTION_FILE_ROWS = 5;
const STARTUP_RECONNECT_DELAY_MS = 3_000;

type MentionRemoteCacheEntry = {
Expand Down Expand Up @@ -7315,7 +7319,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,

const publishSuggestions = (remote: MentionSuggestion[] = []) => {
if (cancelled) return;
const next = [...localSuggestions(), ...remote, ...attachedSuggestions()].slice(0, 10);
const local = localSuggestions();
// On a bare `@` every lane and chat matches, so without a reservation the
// row cap would drop the whole browse list of files. Only browse mode
// trims locals; typed queries keep their existing ordering untouched.
const fileRows = query ? 0 : Math.min(remote.filter((s) => s.kind === "file").length, MENTION_FILE_ROWS);
const localBudget = Math.max(0, MENTION_MAX_ROWS - fileRows);
const next = [...local.slice(0, localBudget), ...remote, ...attachedSuggestions()].slice(0, MENTION_MAX_ROWS);
setMentionSuggestions(next);
setMentionIndex((index) => Math.min(index, Math.max(0, next.length - 1)));
};
Expand All @@ -7325,21 +7335,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
const remote: MentionSuggestion[] = [];
if (conn && laneId) {
const cache = mentionRemoteCacheEntry(mentionRemoteCacheRef.current, laneId);
const filesPromise = query
? cache.filesByQuery.get(query)
? Promise.resolve(cache.filesByQuery.get(query)!)
: Promise.resolve(conn.action<Array<{ path: string }>>("file", "quickOpen", {
workspaceId: laneId,
query,
limit: 5,
}))
.then((files) => {
const safeFiles = Array.isArray(files) ? files : [];
cache.filesByQuery.set(query, safeFiles);
return safeFiles;
})
.catch(() => [])
: Promise.resolve([] as Array<{ path: string }>);
// An empty query is a valid request: it browses the workspace
// (shallowest paths first) instead of returning nothing, matching the
// desktop composer's `@` behavior. The cache keys on the query string,
// so "" caches like any typed query.
const filesPromise = cache.filesByQuery.get(query)
? Promise.resolve(cache.filesByQuery.get(query)!)
: Promise.resolve(conn.action<Array<{ path: string }>>("file", "quickOpen", {
workspaceId: laneId,
query,
limit: MENTION_FILE_ROWS,
}))
.then((files) => {
const safeFiles = Array.isArray(files) ? files : [];
cache.filesByQuery.set(query, safeFiles);
return safeFiles;
})
.catch(() => []);
const commitsPromise = cache.commits
? Promise.resolve(cache.commits)
: Promise.resolve(conn.action<Array<Record<string, unknown>>>("git", "listRecentCommits", {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, powerMonitor, protocol, safeStorage, shell } from "electron";

Check warning on line 1 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'shell' is defined but never used. Allowed unused vars must match /^_/u

if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) {
process.env.ADE_RUNTIME_PACKAGED = "1";
Expand Down Expand Up @@ -50,7 +50,7 @@
getSharedProductAnalyticsService,
} from "./services/analytics/productAnalyticsService";
import { detectInstallSource } from "./services/analytics/installSource";
import { captureAgentTurnSettledAnalytics } from "./services/analytics/agentTurnProductAnalytics";
import { captureAgentTurnSettledAnalytics, captureChatMentionsExpandedAnalytics } from "./services/analytics/agentTurnProductAnalytics";
import { initPerfRunFromEnv } from "./services/perf/perfLog";
import { startMetricsSampler } from "./services/perf/metricsSampler";
import { registerPerfIpcHandlers } from "./services/perf/perfIpc";
Expand Down Expand Up @@ -203,7 +203,7 @@
import { localIpcListenOptions } from "../../../ade-cli/src/services/runtime/localIpcListenOptions";
import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots";
import {
ACCOUNT_SESSION_CREDENTIAL_KEY,

Check warning on line 206 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'ACCOUNT_SESSION_CREDENTIAL_KEY' is defined but never used. Allowed unused vars must match /^_/u
getSignedInAccountAccessToken,
} from "../../../ade-cli/src/services/account/accountAuthService";
import { createPushRelayClient } from "../../../ade-cli/src/services/push/pushRelayClient";
Expand Down Expand Up @@ -3350,6 +3350,11 @@
projectId,
event,
}),
onChatMentionsExpanded: (event) => captureChatMentionsExpandedAnalytics({
analytics: productAnalyticsService,
projectId,
sessionId: event.sessionId,
}),
onSessionEnded: onTrackedSessionEnded,
getDirtyFileTextForPath: async (absPath: string) => {
const trimmed = absPath.trim();
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import type {
AttentionPresence,
} from "../../../shared/types/attention";
import type { ComputerUseOwnerSnapshotArgs } from "../../../shared/types/computerUseArtifacts";
import type {
ChatMentionSuggestArgs,
ChatMentionSuggestResult,
} from "../../../shared/types/chatMentions";
import type {
AgentChatFileSearchArgs,
AgentChatFileSearchResult,
Expand Down Expand Up @@ -625,6 +629,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"approveToolUse",
"codexFuzzyFileSearch",
"fileSearch",
"listMentionSuggestions",
"handoffSession",
"prepareCrossMachineHandoff",
"validateCrossMachineSource",
Expand Down Expand Up @@ -1746,6 +1751,23 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null {
...(typeof match.score === "number" ? { score: match.score } : {}),
}));
},
// Composer @-mention suggestions (chats / lanes / terminals) for the
// active project. Roster-only reads; no transcript or PTY work happens
// here, so this is safe at keystroke rate.
listMentionSuggestions: (args?: unknown): Promise<ChatMentionSuggestResult> => {
// Action args cross a process boundary, so narrow rather than cast: only
// the two string fields of the contract are forwarded.
const record = (args ?? {}) as Record<string, unknown>;
const query = typeof record.query === "string" ? record.query : "";
const excludeSessionId = typeof record.excludeSessionId === "string"
? record.excludeSessionId
: undefined;
const suggestArgs: ChatMentionSuggestArgs = {
query,
...(excludeSessionId ? { excludeSessionId } : {}),
};
return agentChatService.listMentionSuggestions(suggestArgs);
},
getTurnFileDiff: (args?: AgentChatGetTurnFileDiffArgs) => {
if (!args) throw new Error("Turn file diff args are required.");
return getTurnFileDiffFromGit(runtime.projectRoot, args);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@ import type { ProductAnalyticsService } from "./productAnalyticsService";

type AgentTurnAnalytics = Pick<ProductAnalyticsService, "captureInternal">;

/**
* One coarse adoption fact when a send's composer @-mentions were expanded
* into pointer blocks. Identity only — no mention targets, titles, previews,
* or counts. The installation-wide dedupe key plus a one-hour minimum interval
* bounds this to at most 24 accepted events per UTC day, inside the existing
* `ade_feature_used` and shared ceilings.
*/
export function captureChatMentionsExpandedAnalytics(args: {
analytics: AgentTurnAnalytics;
projectId: string;
sessionId: string | null;
}): void {
args.analytics.captureInternal({
event: "ade_feature_used",
surface: "api",
projectId: args.projectId,
...(args.sessionId ? { sessionId: args.sessionId } : {}),
dedupeKey: "chat_mention_expanded",
minimumIntervalMs: 60 * 60_000,
properties: {
feature: "chat",
action: "mention_expanded",
outcome: "completed",
source: "runtime",
},
});
}

export function captureAgentTurnSettledAnalytics(args: {
analytics: AgentTurnAnalytics;
projectId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const ANALYTICS_ONLY_ACTIONS = new Set([
"header_opened",
"preferences_changed",
"brain_repair",
"mention_expanded",
]);

const EVENT_PROPERTY_KEYS: Record<ProductAnalyticsEventName, ReadonlySet<string>> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,41 @@ describe("productAnalyticsService", () => {
fs.rmSync(harness.root, { recursive: true, force: true });
});

it("accepts the chat mention_expanded fact and bounds a mention-heavy day by dedupe", () => {
const harness = makeHarness();
expect(harness.service.captureInternal({
event: "ade_feature_used",
surface: "api",
properties: {
feature: "chat",
action: "mention_expanded",
outcome: "completed",
source: "runtime",
mention_titles: "Fix login lane, sync debugging chat",
},
dedupeKey: "chat_mention_expanded",
minimumIntervalMs: 60 * 60_000,
})).toEqual({ accepted: true, reason: "accepted" });
expect(harness.messages[0]?.properties).toMatchObject({
feature: "chat",
action: "mention_expanded",
outcome: "completed",
source: "runtime",
});
// Entity titles are user text; the sanitizer must strip the unknown key.
expect(harness.messages[0]?.properties).not.toHaveProperty("mention_titles");
// Second mention-send inside the hour: dropped, so a mention-heavy session
// costs at most one accepted event per hour.
expect(harness.service.captureInternal({
event: "ade_feature_used",
surface: "api",
properties: { feature: "chat", action: "mention_expanded", outcome: "completed", source: "runtime" },
dedupeKey: "chat_mention_expanded",
minimumIntervalMs: 60 * 60_000,
})).toEqual({ accepted: false, reason: "duplicate" });
fs.rmSync(harness.root, { recursive: true, force: true });
});

it("accepts only coarse transactional update telemetry properties", () => {
const harness = makeHarness();

Expand Down
56 changes: 56 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19155,6 +19155,62 @@ describe("createAgentChatService", () => {
]));
});

// Regression: mention expansion once lived only in steerUserMessage, which
// the daemon-routed exported steer() never calls — packaged builds shipped
// raw chips. Expansion now sits in steerWithOptions, the single funnel, so
// the exported steer must deliver <ade-mention> blocks to the provider
// while the transcript keeps the user's literal chip text.
it("expands @-mention chips on the exported steer path before provider delivery", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});

const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.4",
});

await service.sendMessage({
sessionId: session.id,
text: "Start working",
}, { awaitDispatch: true });
await waitForEvent(
events,
(event): event is AgentChatEventEnvelope & {
event: Extract<AgentChatEventEnvelope["event"], { type: "status" }>;
} =>
event.event.type === "status"
&& event.event.turnStatus === "started"
&& event.event.turnId === "turn-1",
);

const raw = "apply the fix from @chat:other-session-id";
const result = await service.steer({ sessionId: session.id, text: raw });
expect(result.queued).toBe(false);

const steerPayload = mockState.codexRequestPayloads.find(
(payload) => payload.method === "turn/steer",
);
expect(steerPayload, "steer must reach the provider").toBeTruthy();
const providerText = JSON.stringify(steerPayload!.params);
// The provider sees the pointer block (unresolved here — the fixture has
// no such chat — which still proves expansion ran on this path).
expect(providerText).toContain("<ade-mention");
expect(providerText).toContain("other-session-id");
// The transcript keeps the literal chip, not the expansion blob.
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
event: expect.objectContaining({
type: "user_message",
text: raw,
steerId: result.steerId,
}),
}),
]));
});

it("adopts Codex active turn mismatches and retries delivered steers", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
Expand Down
Loading
Loading