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
136 changes: 129 additions & 7 deletions src/frontends/telegram/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ interface PendingApproval {
/** Cap the pending-approval map so untapped prompts (interrupted turns, closed
* chats) can't grow it unbounded, and evict anything older than the TTL. */
const MAX_PENDING_APPROVALS = 200;
/** Cap on parked search queries awaiting a "search all workspaces" tap. */
const MAX_PENDING_SEARCHES = 200;
const APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour

/**
Expand Down Expand Up @@ -150,6 +152,13 @@ export class TelegramFrontend implements Frontend {

/** `${userId}:${short}` → pending provider dialog (`session.ui_request`). */
#uiRequests = new Map<string, PendingUiReq>();

/**
* `${userId}:${short}` → the query behind a "search all workspaces" button.
* Telegram callback_data caps at 64 bytes, so the query is held here rather
* than encoded into the button.
*/
#pendingSearches = new Map<string, string>();
/** Approval time-to-live (ms); overridable so tests can exercise expiry. */
#approvalTtlMs: number;
/** Send surface handed to each user's StreamRelay. */
Expand Down Expand Up @@ -206,7 +215,7 @@ export class TelegramFrontend implements Frontend {
{ command: "fork", description: "Branch this session: /fork [backend]" },
{ command: "rotate", description: "Fresh context (memory kept)" },
{ command: "rename", description: "Rename the attached session" },
{ command: "search", description: "Search across sessions" },
{ command: "search", description: "Search sessions: /search <query>" },
{ command: "agents", description: "Subagents available" },
{ command: "skills", description: "Skills available" },
{ command: "mcp", description: "MCP servers" },
Expand Down Expand Up @@ -567,16 +576,48 @@ export class TelegramFrontend implements Frontend {

const query = (ctx.message?.text?.split(/\s+/).slice(1).join(" ") ?? "").trim();
if (!query) {
await ctx.reply("Usage: /search <query>\n\nSearches across all sessions in the workspace.");
await ctx.reply(
"Usage: /search <query>\n\n" +
"Searches the attached session's workspace. Not attached? Searches " +
"every workspace. A button on the results switches to all workspaces.",
);
return;
}

// Attached = there is a workspace worth scoping to, and it is the one the
// user is looking at. Unattached, there is nothing to anchor on, so go
// straight to cross-workspace rather than letting the daemon guess from
// the most recently created session — which is rarely what was meant.
const scope: "workspace" | "all" = state.attachedSessionId ? "workspace" : "all";
await this.#runSearch(ctx, state, query, scope);
}

/**
* Execute one search and render it. Split out of `#handleSearch` so the
* "search all workspaces" button can re-run the same query in the other
* scope, which is Telegram's equivalent of the TUI's Tab toggle.
*/
async #runSearch(
ctx: Context,
state: UserState,
query: string,
scope: "workspace" | "all",
): Promise<void> {
// Anchor on the ATTACHED session's workdir. Omitting it made the daemon
// fall back to `#guessCallerWorkdir`, which picks the most recently
// created session — so "this workspace" could mean a repo the user is
// not attached to and never asked about.
const attached = state.attachedSessionName
? this.#manager.findByName(state.attachedSessionName, state.auth!)
: undefined;

const resp = await this.#manager.handle(
{
type: "session.search",
id: randomUUID(),
query,
scope: "workspace",
scope,
...(scope === "workspace" && attached?.workdir ? { workdir: attached.workdir } : {}),
limit: 5,
},
state.auth!,
Expand All @@ -591,19 +632,32 @@ export class TelegramFrontend implements Frontend {
if (resp.type !== "session.search.result") return;

const results = resp as SessionSearchResultMsg;

// Offered whenever the search was scoped, including on zero hits — that
// is exactly when widening is most useful.
const widen = scope === "workspace" ? this.#widenKeyboard(ctx, query) : undefined;

if (results.sessions.length === 0) {
await ctx.reply(`No results for "${query}".`);
const where = scope === "all" ? "any workspace" : "this workspace";
await ctx.reply(`No results for "${query}" in ${where}.`, {
...(widen ? { reply_markup: widen } : {}),
});
return;
}

const lines: string[] = [];
lines.push(`🔍 *Search: ${escMd(query)}*\n`);
const scopeLabel = scope === "all" ? "all workspaces" : "this workspace";
lines.push(`🔍 *Search: ${escMd(query)}* — ${escMd(scopeLabel)}\n`);

for (const hit of results.sessions) {
const when = formatAgo(hit.lastMatchAt);
const matchLabel = `${hit.matchCount} match${hit.matchCount === 1 ? "" : "es"}`;
// Cross-workspace names are ambiguous on their own — two repos can each
// have a "fix auth" session — so name the repo when the scope is global.
const repoName = scope === "all" ? basename(hit.workdir) : "";
const repo = repoName ? ` \\[${escMd(repoName)}\\]` : "";
lines.push(
`▸ *${escMd(hit.sessionName)}* — ${escMd(matchLabel)} · ${escMd(when)}`,
`▸ *${escMd(hit.sessionName)}*${repo} — ${escMd(matchLabel)} · ${escMd(when)}`,
);

for (const snippet of hit.snippets.slice(0, 2)) {
Expand All @@ -627,14 +681,40 @@ export class TelegramFrontend implements Frontend {
// Telegram has a 4096-char limit; chunk if needed
const text = lines.join("\n");
if (text.length <= 4096) {
await ctx.reply(text, { parse_mode: "MarkdownV2" });
await ctx.reply(text, {
parse_mode: "MarkdownV2",
...(widen ? { reply_markup: widen } : {}),
});
} else {
// Fall back to plain text for very long results
const plain = lines.join("\n").replace(/\\([_*[\]()~`>#+\-=|{}.!\\])/g, "$1");
state.relay.sendChunked(ctx.chat!.id, plain);
// sendChunked has no keyboard surface, so offer the widen control
// as its own follow-up message rather than dropping it.
if (widen) {
await ctx.reply("Search every workspace?", { reply_markup: widen }).catch(() => {});
}
}
}

/**
* Build the "search all workspaces" control — Telegram's stand-in for the
* TUI's Tab toggle. The query is parked in `#pendingSearches` because
* callback_data is capped at 64 bytes and queries are free text.
*/
#widenKeyboard(ctx: Context, query: string): InlineKeyboard | undefined {
const userId = ctx.from?.id;
if (userId === undefined) return undefined;
if (this.#pendingSearches.size >= MAX_PENDING_SEARCHES) {
// Oldest-first eviction; Map preserves insertion order.
const oldest = this.#pendingSearches.keys().next();
if (!oldest.done) this.#pendingSearches.delete(oldest.value);
}
const short = randomUUID().slice(0, 8);
this.#pendingSearches.set(`${userId}:${short}`, query);
return new InlineKeyboard().text("🌐 Search all workspaces", `srchall:${short}`);
}

// ── Run control ───────────────────────────────────────────────────────

async #handleRename(ctx: Context): Promise<void> {
Expand Down Expand Up @@ -1254,6 +1334,36 @@ export class TelegramFrontend implements Frontend {
return;
}

// 🌐 Search all workspaces — re-run a scoped search globally. Keyed
// `${userId}:${short}` like approvals because the query cannot fit in
// callback_data.
if (kind === "srchall") {
const userId = ctx.from?.id;
const state = userId !== undefined ? this.#users.get(userId) : undefined;
if (!state?.auth) {
await ctx.answerCallbackQuery({ text: "Session expired — re-run /auth." }).catch(() => {});
return;
}
if (this.#authExpired(state)) {
this.#expireAuth(userId!, state);
await ctx.answerCallbackQuery({ text: "Session expired — re-run /auth." }).catch(() => {});
return;
}
const key = `${userId}:${short}`;
const query = this.#pendingSearches.get(key);
if (query === undefined) {
await ctx.answerCallbackQuery({ text: "Search no longer active — re-run /search." }).catch(() => {});
await ctx.editMessageReplyMarkup().catch(() => {});
return;
}
this.#pendingSearches.delete(key);
await ctx.answerCallbackQuery({ text: "🌐 Searching every workspace…" }).catch(() => {});
// Drop the button so the same widen cannot be fired twice.
await ctx.editMessageReplyMarkup().catch(() => {});
await this.#runSearch(ctx, state, query, "all");
return;
}

// Provider dialog answer (`session.ui_request`). Keyed `${userId}:${short}`
// like approvals; `rest[0]` is the choice: o<idx> | y | n | x(cancel).
if (kind === "uireq") {
Expand Down Expand Up @@ -1572,6 +1682,18 @@ export function isStaleBroadcast(
}

/** Relative time string from a unix-ms timestamp. */
/**
* Last path segment of a workdir, for labelling cross-workspace hits.
* Total on purpose: a hit whose session is no longer live can carry an
* empty workdir, and a missing repo label must not break the result list.
*/
function basename(p: string | undefined): string {
if (!p) return "";
const trimmed = p.replace(/[/\\]+$/, "");
const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return cut === -1 ? trimmed : trimmed.slice(cut + 1);
}

function formatAgo(when: number): string {
const dt = Math.max(0, Date.now() - when);
if (dt < 60_000) return "just now";
Expand Down
71 changes: 68 additions & 3 deletions src/tests/telegram-flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ afterEach(async () => {

/** Recording fake SessionManager: two known sessions, capture attach clients. */
function makeFakeManager() {
const sessions: Record<string, { id: string; name: string }> = {
alpha: { id: "sess-a", name: "alpha" },
beta: { id: "sess-b", name: "beta" },
const sessions: Record<string, { id: string; name: string; workdir: string }> = {
alpha: { id: "sess-a", name: "alpha", workdir: "/repos/alpha" },
beta: { id: "sess-b", name: "beta", workdir: "/repos/beta" },
};
const handled: any[] = [];
const disconnected: string[] = [];
Expand Down Expand Up @@ -720,6 +720,71 @@ describe("Telegram flows — switch, failed re-attach, detach, destroy, search",
expect(manager.handled.some((m) => m.type === "session.destroy" && m.sessionId === "sess-a")).toBe(true);
});

it("/search while attached anchors on the attached session's workspace", async () => {
const { drive, manager, texts } = await boot();

await drive("/attach alpha");
await until(() => texts().some((t) => t.startsWith("Attached to")));

await drive("/search needle");
await until(() => manager.handled.some((m: any) => m.type === "session.search"));

const search = manager.handled.find((m: any) => m.type === "session.search")!;
expect(search.scope).toBe("workspace");
// Anchored on the ATTACHED session, not left for the daemon to guess from
// whichever session happens to have been created most recently.
expect(search.workdir).toBe("/repos/alpha");
});

it("/search with no attached session searches every workspace", async () => {
const { drive, manager, texts } = await boot();

await drive("/search needle");
await until(() => texts().some((t) => t.includes("Search: needle")));

const search = manager.handled.find((m: any) => m.type === "session.search")!;
// Nothing to anchor on, so don't scope to an arbitrary workspace.
expect(search.scope).toBe("all");
expect(search.workdir).toBeUndefined();
});

it("the widen button re-runs the query across all workspaces", async () => {
const { drive, driveCallback, manager, sent, texts } = await boot();

await drive("/attach alpha");
await until(() => texts().some((t) => t.startsWith("Attached to")));

await drive("/search needle");
await until(() => manager.handled.some((m: any) => m.type === "session.search"));

// A scoped search offers the widen control.
const withKb = sent().find((c) =>
JSON.stringify(c.payload.reply_markup ?? {}).includes("srchall:"),
);
expect(withKb).toBeDefined();
const data = JSON.stringify(withKb!.payload.reply_markup).match(
/"(srchall:[0-9a-f]{8})"/,
)![1]!;

await driveCallback(data);
await until(
() => manager.handled.filter((m: any) => m.type === "session.search").length === 2,
);

const searches = manager.handled.filter((m: any) => m.type === "session.search");
expect(searches[0].scope).toBe("workspace");
expect(searches[1].scope).toBe("all");
// Same query, other scope — the Telegram equivalent of the TUI's Tab.
expect(searches[1].query).toBe("needle");
expect(searches[1].workdir).toBeUndefined();

// The token is single-use; a second tap finds nothing parked.
await driveCallback(data);
expect(
manager.handled.filter((m: any) => m.type === "session.search").length,
).toBe(2);
});

it("falls back to plain-text chunked output for >4096-char search results", async () => {
const { drive, sent, texts } = await boot();

Expand Down
9 changes: 7 additions & 2 deletions src/tui/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,13 @@ function SearchHitRow({
);
}

/** Last path segment of a workdir, for labelling cross-workspace hits. */
function basename(p: string): string {
/**
* Last path segment of a workdir, for labelling cross-workspace hits.
* Total on purpose: a hit whose session is no longer live can carry an
* empty workdir, and a missing repo label must not break the result list.
*/
function basename(p: string | undefined): string {
if (!p) return "";
const trimmed = p.replace(/[/\\]+$/, "");
const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return cut === -1 ? trimmed : trimmed.slice(cut + 1);
Expand Down
Loading