From 0f71ae6ada043cada4c4988ffb93b6579173942a Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 29 Aug 2026 23:46:10 +0800 Subject: [PATCH] feat: reach cross-workspace session search from Telegram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #309 gave the TUI a Tab toggle between this-workspace and all-workspaces search. Telegram was left on the old path, so the same capability was still unreachable there. Two problems, not one. The scope was wrong even when it worked. `/search` sent scope:"workspace" with no workdir, so the daemon fell through to `#guessCallerWorkdir`, which returns the workdir of the most recently CREATED session by that user. That is unrelated to what the Telegram user is attached to β€” you could be attached to alpha, ask about alpha, and silently search beta because beta was started later. The handler now anchors on the attached session's workdir. With nothing attached there is no workspace worth scoping to, so the search runs cross-workspace instead of scoping to an arbitrary repo. Same rule the TUI modal uses for its initial scope. Telegram has no Tab, so the widen control is an inline-keyboard button on the results: "🌐 Search all workspaces". It is offered on zero hits too, which is exactly when widening is most useful. callback_data caps at 64 bytes and queries are free text, so the query is parked in `#pendingSearches` keyed `${userId}:${short}` β€” the same shape as approvals and provider dialogs β€” with oldest-first eviction at 200 entries and single-use tokens. Cross-workspace hits show the repo name, as in the TUI: two repos can each have a "fix auth" session, so a bare session name is ambiguous once results span workspaces. `basename` is total in both frontends. A hit whose session is no longer live can carry an empty workdir β€” the daemon already renders those as "(unknown)" β€” and a missing repo label must not break the result list. The TUI copy had the same latent hazard and is fixed alongside. Co-authored-by: Claude Opus 5 (1M context) --- src/frontends/telegram/index.ts | 136 +++++++++++++++++++++++++++++-- src/tests/telegram-flows.test.ts | 71 +++++++++++++++- src/tui/components/Modal.tsx | 9 +- 3 files changed, 204 insertions(+), 12 deletions(-) diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts index 532b1cb..8a16ae7 100644 --- a/src/frontends/telegram/index.ts +++ b/src/frontends/telegram/index.ts @@ -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 /** @@ -150,6 +152,13 @@ export class TelegramFrontend implements Frontend { /** `${userId}:${short}` β†’ pending provider dialog (`session.ui_request`). */ #uiRequests = new Map(); + + /** + * `${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(); /** Approval time-to-live (ms); overridable so tests can exercise expiry. */ #approvalTtlMs: number; /** Send surface handed to each user's StreamRelay. */ @@ -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 " }, { command: "agents", description: "Subagents available" }, { command: "skills", description: "Skills available" }, { command: "mcp", description: "MCP servers" }, @@ -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 \n\nSearches across all sessions in the workspace."); + await ctx.reply( + "Usage: /search \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 { + // 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!, @@ -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)) { @@ -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 { @@ -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 | y | n | x(cancel). if (kind === "uireq") { @@ -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"; diff --git a/src/tests/telegram-flows.test.ts b/src/tests/telegram-flows.test.ts index 19aed30..e75308f 100644 --- a/src/tests/telegram-flows.test.ts +++ b/src/tests/telegram-flows.test.ts @@ -121,9 +121,9 @@ afterEach(async () => { /** Recording fake SessionManager: two known sessions, capture attach clients. */ function makeFakeManager() { - const sessions: Record = { - alpha: { id: "sess-a", name: "alpha" }, - beta: { id: "sess-b", name: "beta" }, + const sessions: Record = { + alpha: { id: "sess-a", name: "alpha", workdir: "/repos/alpha" }, + beta: { id: "sess-b", name: "beta", workdir: "/repos/beta" }, }; const handled: any[] = []; const disconnected: string[] = []; @@ -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(); diff --git a/src/tui/components/Modal.tsx b/src/tui/components/Modal.tsx index 3a5fadd..c00b5d4 100644 --- a/src/tui/components/Modal.tsx +++ b/src/tui/components/Modal.tsx @@ -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);