From fd57757cab34d795c31bc91415d31a37b607c5f9 Mon Sep 17 00:00:00 2001 From: Sergey Dmitriev Date: Wed, 21 Oct 2026 11:44:54 +0400 Subject: [PATCH 1/2] fix(composer): find a thread by its pasted ID in the @ picker The @ picker matched threads only by title. A thread ID copied with Copy thread reference (mod+shift+c) and pasted after @ showed "No matching files or folders." A thread now also matches when the query equals its whole ID. The match is exact because IDs share long prefixes such as `thread:project:` and `mcp:`, so a prefix or substring match would list many unrelated threads. Co-Authored-By: Claude Opus 5.5 --- .../src/composerThreadItems.test.ts | 29 +++++++++++++++++++ .../client-runtime/src/composerThreadItems.ts | 3 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 packages/client-runtime/src/composerThreadItems.test.ts diff --git a/packages/client-runtime/src/composerThreadItems.test.ts b/packages/client-runtime/src/composerThreadItems.test.ts new file mode 100644 index 0000000000000..fb1f3250198ca --- /dev/null +++ b/packages/client-runtime/src/composerThreadItems.test.ts @@ -0,0 +1,29 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { matchComposerThreadItems } from "./composerThreadItems.ts"; + +const env = EnvironmentId.make("env-1"); +const shell = (id: string, title: string) => ({ + environmentId: env, + id: ThreadId.make(id), + title, + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, +}); + +describe("matchComposerThreadItems", () => { + it("finds a thread by its pasted ID without matching other IDs that share its prefix", () => { + const pasted = + "thread:project:6f1c2a4e-8b7d-4d3a-9c5e-1a2b3c4d5e6f:0b9d7c1a-3e5f-4a6b-8c2d-7e9f1a3b5c7d"; + const sibling = + "thread:project:6f1c2a4e-8b7d-4d3a-9c5e-1a2b3c4d5e6f:0b9d7c1a-3e5f-4a6b-8c2d-7e9f1a3b5c7e"; + const items = matchComposerThreadItems({ + shells: [shell(pasted, "Fix login"), shell(sibling, "Fix logout")], + environmentId: env, + excludeThreadId: null, + query: pasted, + }); + expect(items.map((item) => item.thread.threadId)).toEqual([pasted]); + }); +}); diff --git a/packages/client-runtime/src/composerThreadItems.ts b/packages/client-runtime/src/composerThreadItems.ts index f94de963afe5e..51b25c158e118 100644 --- a/packages/client-runtime/src/composerThreadItems.ts +++ b/packages/client-runtime/src/composerThreadItems.ts @@ -36,7 +36,8 @@ export function matchComposerThreadItems(input: { shell.environmentId === input.environmentId && shell.id !== input.excludeThreadId && shell.archivedAt === null && - shell.title.toLowerCase().includes(query), + // Thread IDs share long prefixes, so only an exact ID matches. + (shell.title.toLowerCase().includes(query) || shell.id.toLowerCase() === query), ) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(0, COMPOSER_THREAD_RESULT_LIMIT) From 7f1f6613ec82ce702162c136849159ef6c323500 Mon Sep 17 00:00:00 2001 From: Sergey Dmitriev Date: Sat, 24 Oct 2026 10:25:09 +0400 Subject: [PATCH 2/2] fix(composer): match pasted thread IDs case-sensitively Imported thread IDs keep the provider's session ID, so two IDs can differ only by case. A case-insensitive match listed both, and the newer one could rank first. A pasted ID always keeps its case, so the ID match is now exact. Title search still ignores case. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/composerThreadItems.test.ts | 11 +++++++++++ packages/client-runtime/src/composerThreadItems.ts | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/composerThreadItems.test.ts b/packages/client-runtime/src/composerThreadItems.test.ts index fb1f3250198ca..dcc6868311d0f 100644 --- a/packages/client-runtime/src/composerThreadItems.test.ts +++ b/packages/client-runtime/src/composerThreadItems.test.ts @@ -26,4 +26,15 @@ describe("matchComposerThreadItems", () => { }); expect(items.map((item) => item.thread.threadId)).toEqual([pasted]); }); + + it("does not match an ID that differs from the pasted one only by case", () => { + const pasted = "import:opencode:ses_AbC123"; + const items = matchComposerThreadItems({ + shells: [shell(pasted, "Imported A"), shell("import:opencode:ses_abc123", "Imported B")], + environmentId: env, + excludeThreadId: null, + query: pasted, + }); + expect(items.map((item) => item.thread.threadId)).toEqual([pasted]); + }); }); diff --git a/packages/client-runtime/src/composerThreadItems.ts b/packages/client-runtime/src/composerThreadItems.ts index 51b25c158e118..d4a96c6e22e64 100644 --- a/packages/client-runtime/src/composerThreadItems.ts +++ b/packages/client-runtime/src/composerThreadItems.ts @@ -28,7 +28,8 @@ export function matchComposerThreadItems(input: { excludeThreadId: ThreadId | null; query: string; }): ComposerThreadItem[] { - const query = input.query.trim().toLowerCase(); + const pastedId = input.query.trim(); + const query = pastedId.toLowerCase(); if (query.length === 0) return []; return input.shells .filter( @@ -36,8 +37,8 @@ export function matchComposerThreadItems(input: { shell.environmentId === input.environmentId && shell.id !== input.excludeThreadId && shell.archivedAt === null && - // Thread IDs share long prefixes, so only an exact ID matches. - (shell.title.toLowerCase().includes(query) || shell.id.toLowerCase() === query), + // Thread IDs share long prefixes and are case-sensitive, so only the exact ID matches. + (shell.title.toLowerCase().includes(query) || shell.id === pastedId), ) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(0, COMPOSER_THREAD_RESULT_LIMIT)