| author | |
| committer | |
| log | e8556bcff95f4bdd74d4a7334626056f1e26a856 |
| tree | 987218d32eaf5c55da90efb65ed80c8bc2e07e46 |
| parent | a1532d5b2de71e4a2c3d90d9653da02fa931d8d6 |
- add `projects.searchEntries` API, contracts, and server-side indexed workspace search
- add composer trigger logic for `@` path suggestions and `/model` command completion
- include tagged paths in sent prompt/user-visible text and cover behavior with server/web tests12 files changed, 845 insertions(+), 8 deletions(-)
apps/server/src/workspaceEntries.test.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 6 | |||
| 7 | import { searchWorkspaceEntries } from "./workspaceEntries"; | ||
| 8 | |||
| 9 | const tempDirs: string[] = []; | ||
| 10 | |||
| 11 | function makeTempDir(prefix: string): string { | ||
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| 13 | tempDirs.push(dir); | ||
| 14 | return dir; | ||
| 15 | } | ||
| 16 | |||
| 17 | function writeFile(cwd: string, relativePath: string, contents = ""): void { | ||
| 18 | const absolutePath = path.join(cwd, relativePath); | ||
| 19 | fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); | ||
| 20 | fs.writeFileSync(absolutePath, contents, "utf8"); | ||
| 21 | } | ||
| 22 | |||
| 23 | describe("searchWorkspaceEntries", () => { | ||
| 24 | afterEach(() => { | ||
| 25 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | ||
| 26 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 27 | } | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("returns files and directories relative to cwd", async () => { | ||
| 31 | const cwd = makeTempDir("t3code-workspace-entries-"); | ||
| 32 | writeFile(cwd, "src/components/Composer.tsx"); | ||
| 33 | writeFile(cwd, "src/index.ts"); | ||
| 34 | writeFile(cwd, "README.md"); | ||
| 35 | writeFile(cwd, ".git/HEAD"); | ||
| 36 | writeFile(cwd, "node_modules/pkg/index.js"); | ||
| 37 | |||
| 38 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | ||
| 39 | const paths = result.entries.map((entry) => entry.path); | ||
| 40 | |||
| 41 | expect(paths).toContain("src"); | ||
| 42 | expect(paths).toContain("src/components"); | ||
| 43 | expect(paths).toContain("src/components/Composer.tsx"); | ||
| 44 | expect(paths).toContain("README.md"); | ||
| 45 | expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); | ||
| 46 | expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); | ||
| 47 | expect(result.truncated).toBe(false); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("filters and ranks entries by query", async () => { | ||
| 51 | const cwd = makeTempDir("t3code-workspace-query-"); | ||
| 52 | writeFile(cwd, "src/components/Composer.tsx"); | ||
| 53 | writeFile(cwd, "src/components/composePrompt.ts"); | ||
| 54 | writeFile(cwd, "docs/composition.md"); | ||
| 55 | |||
| 56 | const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); | ||
| 57 | |||
| 58 | expect(result.entries.length).toBeGreaterThan(0); | ||
| 59 | expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); | ||
| 60 | expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true); | ||
| 61 | }); | ||
| 62 | }); | ||
apps/server/src/workspaceEntries.ts created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | import fs from "node:fs/promises"; | ||
| 2 | import path from "node:path"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | projectSearchEntriesInputSchema, | ||
| 6 | type ProjectEntry, | ||
| 7 | type ProjectSearchEntriesInput, | ||
| 8 | type ProjectSearchEntriesResult, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | const WORKSPACE_CACHE_TTL_MS = 15_000; | ||
| 12 | const WORKSPACE_CACHE_MAX_KEYS = 4; | ||
| 13 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | ||
| 14 | const IGNORED_DIRECTORY_NAMES = new Set([ | ||
| 15 | ".git", | ||
| 16 | "node_modules", | ||
| 17 | ".next", | ||
| 18 | ".turbo", | ||
| 19 | "dist", | ||
| 20 | "build", | ||
| 21 | "out", | ||
| 22 | ".cache", | ||
| 23 | ]); | ||
| 24 | |||
| 25 | interface WorkspaceIndex { | ||
| 26 | scannedAt: number; | ||
| 27 | entries: ProjectEntry[]; | ||
| 28 | truncated: boolean; | ||
| 29 | } | ||
| 30 | |||
| 31 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); | ||
| 32 | |||
| 33 | function toPosixPath(input: string): string { | ||
| 34 | return input.split(path.sep).join("/"); | ||
| 35 | } | ||
| 36 | |||
| 37 | function parentPathOf(input: string): string | undefined { | ||
| 38 | const separatorIndex = input.lastIndexOf("/"); | ||
| 39 | if (separatorIndex === -1) { | ||
| 40 | return undefined; | ||
| 41 | } | ||
| 42 | return input.slice(0, separatorIndex); | ||
| 43 | } | ||
| 44 | |||
| 45 | function basenameOf(input: string): string { | ||
| 46 | const separatorIndex = input.lastIndexOf("/"); | ||
| 47 | if (separatorIndex === -1) { | ||
| 48 | return input; | ||
| 49 | } | ||
| 50 | return input.slice(separatorIndex + 1); | ||
| 51 | } | ||
| 52 | |||
| 53 | function normalizeQuery(input: string): string { | ||
| 54 | return input.trim().replace(/^[@./]+/, "").toLowerCase(); | ||
| 55 | } | ||
| 56 | |||
| 57 | function scoreEntry(entry: ProjectEntry, query: string): number { | ||
| 58 | if (!query) { | ||
| 59 | return entry.kind === "directory" ? 0 : 1; | ||
| 60 | } | ||
| 61 | |||
| 62 | const normalizedPath = entry.path.toLowerCase(); | ||
| 63 | const normalizedName = basenameOf(normalizedPath); | ||
| 64 | |||
| 65 | if (normalizedName === query) return 0; | ||
| 66 | if (normalizedPath === query) return 1; | ||
| 67 | if (normalizedName.startsWith(query)) return 2; | ||
| 68 | if (normalizedPath.startsWith(query)) return 3; | ||
| 69 | if (normalizedPath.includes(`/${query}`)) return 4; | ||
| 70 | return 5; | ||
| 71 | } | ||
| 72 | |||
| 73 | async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ||
| 74 | let pendingDirectories: string[] = [""]; | ||
| 75 | const entries: ProjectEntry[] = []; | ||
| 76 | let truncated = false; | ||
| 77 | |||
| 78 | while (pendingDirectories.length > 0 && !truncated) { | ||
| 79 | const currentDirectories = pendingDirectories; | ||
| 80 | pendingDirectories = []; | ||
| 81 | const directoryEntries = await Promise.all( | ||
| 82 | currentDirectories.map(async (relativeDir) => { | ||
| 83 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; | ||
| 84 | try { | ||
| 85 | const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); | ||
| 86 | return { relativeDir, dirents }; | ||
| 87 | } catch (error) { | ||
| 88 | if (!relativeDir) { | ||
| 89 | throw new Error( | ||
| 90 | `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`, | ||
| 91 | { cause: error }, | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | return { relativeDir, dirents: null }; | ||
| 95 | } | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | |||
| 99 | for (const directoryEntry of directoryEntries) { | ||
| 100 | const { relativeDir, dirents } = directoryEntry; | ||
| 101 | if (!dirents) { | ||
| 102 | continue; | ||
| 103 | } | ||
| 104 | |||
| 105 | dirents.sort((left, right) => left.name.localeCompare(right.name)); | ||
| 106 | for (const dirent of dirents) { | ||
| 107 | if (!dirent.name || dirent.name === "." || dirent.name === "..") { | ||
| 108 | continue; | ||
| 109 | } | ||
| 110 | if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | if (!dirent.isDirectory() && !dirent.isFile()) { | ||
| 114 | continue; | ||
| 115 | } | ||
| 116 | |||
| 117 | const relativePath = toPosixPath( | ||
| 118 | relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, | ||
| 119 | ); | ||
| 120 | const entry: ProjectEntry = { | ||
| 121 | path: relativePath, | ||
| 122 | kind: dirent.isDirectory() ? "directory" : "file", | ||
| 123 | parentPath: parentPathOf(relativePath), | ||
| 124 | }; | ||
| 125 | entries.push(entry); | ||
| 126 | |||
| 127 | if (dirent.isDirectory()) { | ||
| 128 | pendingDirectories.push(relativePath); | ||
| 129 | } | ||
| 130 | |||
| 131 | if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { | ||
| 132 | truncated = true; | ||
| 133 | break; | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | if (truncated) { | ||
| 138 | break; | ||
| 139 | } | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | return { | ||
| 144 | scannedAt: Date.now(), | ||
| 145 | entries, | ||
| 146 | truncated, | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ||
| 151 | const cached = workspaceIndexCache.get(cwd); | ||
| 152 | if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) { | ||
| 153 | return cached; | ||
| 154 | } | ||
| 155 | |||
| 156 | const next = await buildWorkspaceIndex(cwd); | ||
| 157 | workspaceIndexCache.set(cwd, next); | ||
| 158 | while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { | ||
| 159 | const oldestKey = workspaceIndexCache.keys().next().value; | ||
| 160 | if (!oldestKey) break; | ||
| 161 | workspaceIndexCache.delete(oldestKey); | ||
| 162 | } | ||
| 163 | return next; | ||
| 164 | } | ||
| 165 | |||
| 166 | export async function searchWorkspaceEntries( | ||
| 167 | input: ProjectSearchEntriesInput, | ||
| 168 | ): Promise<ProjectSearchEntriesResult> { | ||
| 169 | const parsed = projectSearchEntriesInputSchema.parse(input); | ||
| 170 | const index = await getWorkspaceIndex(parsed.cwd); | ||
| 171 | const normalizedQuery = normalizeQuery(parsed.query); | ||
| 172 | const candidates = normalizedQuery | ||
| 173 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) | ||
| 174 | : index.entries; | ||
| 175 | |||
| 176 | const ranked = candidates.toSorted((left, right) => { | ||
| 177 | const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); | ||
| 178 | if (scoreDelta !== 0) return scoreDelta; | ||
| 179 | return left.path.localeCompare(right.path); | ||
| 180 | }); | ||
| 181 | |||
| 182 | return { | ||
| 183 | entries: ranked.slice(0, parsed.limit), | ||
| 184 | truncated: index.truncated || ranked.length > parsed.limit, | ||
| 185 | }; | ||
| 186 | } | ||
apps/server/src/wsServer.test.ts+32| ... | @@ -766,6 +766,38 @@ describe("WebSocket Server", () => { | ... | @@ -766,6 +766,38 @@ describe("WebSocket Server", () => { |
| 766 | expect(afterRemove.result).toEqual([]); | 766 | expect(afterRemove.result).toEqual([]); |
| 767 | }); | 767 | }); |
| 768 | 768 | ||
| 769 | it("supports projects.searchEntries", async () => { | ||
| 770 | const workspace = makeTempDir("t3code-ws-workspace-entries-"); | ||
| 771 | fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); | ||
| 772 | fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); | ||
| 773 | fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); | ||
| 774 | fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); | ||
| 775 | fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); | ||
| 776 | |||
| 777 | server = createTestServer({ cwd: "/test" }); | ||
| 778 | await server.start(); | ||
| 779 | const addr = server.httpServer.address(); | ||
| 780 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 781 | |||
| 782 | const ws = await connectWs(port); | ||
| 783 | connections.push(ws); | ||
| 784 | await waitForMessage(ws); | ||
| 785 | |||
| 786 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | ||
| 787 | cwd: workspace, | ||
| 788 | query: "comp", | ||
| 789 | limit: 10, | ||
| 790 | }); | ||
| 791 | expect(response.error).toBeUndefined(); | ||
| 792 | expect(response.result).toEqual({ | ||
| 793 | entries: expect.arrayContaining([ | ||
| 794 | expect.objectContaining({ path: "src/components", kind: "directory" }), | ||
| 795 | expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), | ||
| 796 | ]), | ||
| 797 | truncated: false, | ||
| 798 | }); | ||
| 799 | }); | ||
| 800 | |||
| 769 | it("supports git methods over websocket", async () => { | 801 | it("supports git methods over websocket", async () => { |
| 770 | const repoCwd = makeTempDir("t3code-ws-git-project-"); | 802 | const repoCwd = makeTempDir("t3code-ws-git-project-"); |
| 771 | 803 |
apps/server/src/wsServer.ts+4| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | } from "./git"; | 32 | } from "./git"; |
| 33 | import { TerminalManager } from "./terminalManager"; | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | ||
| 35 | 36 | ||
| 36 | const MIME_TYPES: Record<string, string> = { | 37 | const MIME_TYPES: Record<string, string> = { |
| 37 | ".html": "text/html; charset=utf-8", | 38 | ".html": "text/html; charset=utf-8", |
| ... | @@ -319,6 +320,9 @@ export function createServer(options: ServerOptions) { | ... | @@ -319,6 +320,9 @@ export function createServer(options: ServerOptions) { |
| 319 | projectRegistry.remove(request.params as never); | 320 | projectRegistry.remove(request.params as never); |
| 320 | return undefined; | 321 | return undefined; |
| 321 | 322 | ||
| 323 | case WS_METHODS.projectsSearchEntries: | ||
| 324 | return searchWorkspaceEntries(request.params as never); | ||
| 325 | |||
| 322 | case WS_METHODS.shellOpenInEditor: { | 326 | case WS_METHODS.shellOpenInEditor: { |
| 323 | const params = request.params as { | 327 | const params = request.params as { |
| 324 | cwd: string; | 328 | cwd: string; |
apps/web/src/components/ChatView.tsx+295-8| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | EDITORS, | 2 | EDITORS, |
| 3 | type EditorId, | 3 | type EditorId, |
| 4 | type ProjectEntry, | ||
| 4 | ModelSlug, | 5 | ModelSlug, |
| 5 | type NativeApi, | 6 | type NativeApi, |
| 6 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 7 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| ... | @@ -31,6 +32,12 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | ... | @@ -31,6 +32,12 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; |
| 31 | 32 | ||
| 32 | import { isElectron } from "../env"; | 33 | import { isElectron } from "../env"; |
| 33 | import { buildBootstrapInput } from "../historyBootstrap"; | 34 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 35 | import { | ||
| 36 | buildPromptInput, | ||
| 37 | buildUserVisiblePrompt, | ||
| 38 | detectComposerTrigger, | ||
| 39 | replaceTextRange, | ||
| 40 | } from "../composer-logic"; | ||
| 34 | import { | 41 | import { |
| 35 | DEFAULT_MODEL, | 42 | DEFAULT_MODEL, |
| 36 | DEFAULT_REASONING, | 43 | DEFAULT_REASONING, |
| ... | @@ -69,6 +76,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | ... | @@ -69,6 +76,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 69 | import { | 76 | import { |
| 70 | ChevronDownIcon, | 77 | ChevronDownIcon, |
| 71 | CircleAlertIcon, | 78 | CircleAlertIcon, |
| 79 | FileIcon, | ||
| 80 | FolderIcon, | ||
| 72 | FolderClosedIcon, | 81 | FolderClosedIcon, |
| 73 | InfoIcon, | 82 | InfoIcon, |
| 74 | LockIcon, | 83 | LockIcon, |
| ... | @@ -83,6 +92,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" | ... | @@ -83,6 +92,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 83 | import { CursorIcon, Icon } from "./Icons"; | 92 | import { CursorIcon, Icon } from "./Icons"; |
| 84 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 93 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 85 | import { Badge } from "./ui/badge"; | 94 | import { Badge } from "./ui/badge"; |
| 95 | import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command"; | ||
| 86 | 96 | ||
| 87 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 97 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 88 | if (!duration) return formatTimestamp(createdAt); | 98 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -103,6 +113,12 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ... | @@ -103,6 +113,12 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 103 | return "text-muted-foreground/40"; | 113 | return "text-muted-foreground/40"; |
| 104 | } | 114 | } |
| 105 | 115 | ||
| 116 | function basenameOfPath(pathValue: string): string { | ||
| 117 | const slashIndex = pathValue.lastIndexOf("/"); | ||
| 118 | if (slashIndex === -1) return pathValue; | ||
| 119 | return pathValue.slice(slashIndex + 1); | ||
| 120 | } | ||
| 121 | |||
| 106 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; | 122 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; |
| 107 | 123 | ||
| 108 | interface EnsuredSessionInfo { | 124 | interface EnsuredSessionInfo { |
| ... | @@ -121,6 +137,29 @@ interface ExpandedImagePreview { | ... | @@ -121,6 +137,29 @@ interface ExpandedImagePreview { |
| 121 | name: string; | 137 | name: string; |
| 122 | } | 138 | } |
| 123 | 139 | ||
| 140 | type ComposerCommandItem = | ||
| 141 | | { | ||
| 142 | id: string; | ||
| 143 | type: "path"; | ||
| 144 | path: string; | ||
| 145 | pathKind: ProjectEntry["kind"]; | ||
| 146 | label: string; | ||
| 147 | description: string; | ||
| 148 | } | ||
| 149 | | { | ||
| 150 | id: string; | ||
| 151 | type: "slash-command"; | ||
| 152 | label: string; | ||
| 153 | description: string; | ||
| 154 | } | ||
| 155 | | { | ||
| 156 | id: string; | ||
| 157 | type: "model"; | ||
| 158 | model: ModelSlug; | ||
| 159 | label: string; | ||
| 160 | description: string; | ||
| 161 | }; | ||
| 162 | |||
| 124 | function readFileAsDataUrl(file: File): Promise<string> { | 163 | function readFileAsDataUrl(file: File): Promise<string> { |
| 125 | return new Promise((resolve, reject) => { | 164 | return new Promise((resolve, reject) => { |
| 126 | const reader = new FileReader(); | 165 | const reader = new FileReader(); |
| ... | @@ -146,6 +185,7 @@ export default function ChatView() { | ... | @@ -146,6 +185,7 @@ export default function ChatView() { |
| 146 | gitCreateWorktreeMutationOptions({ api, queryClient }), | 185 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 147 | ); | 186 | ); |
| 148 | const [prompt, setPrompt] = useState(""); | 187 | const [prompt, setPrompt] = useState(""); |
| 188 | const [taggedPaths, setTaggedPaths] = useState<string[]>([]); | ||
| 149 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | 189 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 150 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 190 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 151 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); | 191 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | @@ -158,6 +198,8 @@ export default function ChatView() { | ... | @@ -158,6 +198,8 @@ export default function ChatView() { |
| 158 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 198 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 159 | const [nowTick, setNowTick] = useState(() => Date.now()); | 199 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 160 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 200 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 201 | const [composerCursor, setComposerCursor] = useState(0); | ||
| 202 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); | ||
| 161 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 203 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 162 | const messagesEndRef = useRef<HTMLDivElement>(null); | 204 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 163 | const shouldAutoScrollRef = useRef(true); | 205 | const shouldAutoScrollRef = useRef(true); |
| ... | @@ -268,11 +310,79 @@ export default function ChatView() { | ... | @@ -268,11 +310,79 @@ export default function ChatView() { |
| 268 | sandboxMode: "workspace-write", | 310 | sandboxMode: "workspace-write", |
| 269 | } as const); | 311 | } as const); |
| 270 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | 312 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 313 | const composerTrigger = useMemo( | ||
| 314 | () => detectComposerTrigger(prompt, composerCursor), | ||
| 315 | [prompt, composerCursor], | ||
| 316 | ); | ||
| 271 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 317 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 272 | const keybindingsQuery = useQuery({ | 318 | const keybindingsQuery = useQuery({ |
| 273 | ...serverConfigQueryOptions(api), | 319 | ...serverConfigQueryOptions(api), |
| 274 | select: (config) => config.keybindings, | 320 | select: (config) => config.keybindings, |
| 275 | }); | 321 | }); |
| 322 | const workspaceEntriesQuery = useQuery({ | ||
| 323 | queryKey: [ | ||
| 324 | "composer-workspace-entries", | ||
| 325 | gitCwd, | ||
| 326 | composerTrigger?.kind === "path" ? composerTrigger.query : "", | ||
| 327 | ], | ||
| 328 | enabled: Boolean(api && gitCwd && composerTrigger?.kind === "path"), | ||
| 329 | staleTime: 15_000, | ||
| 330 | queryFn: async () => { | ||
| 331 | if (!api || !gitCwd || composerTrigger?.kind !== "path") { | ||
| 332 | return { entries: [], truncated: false }; | ||
| 333 | } | ||
| 334 | return api.projects.searchEntries({ | ||
| 335 | cwd: gitCwd, | ||
| 336 | query: composerTrigger.query, | ||
| 337 | limit: 80, | ||
| 338 | }); | ||
| 339 | }, | ||
| 340 | }); | ||
| 341 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | ||
| 342 | if (!composerTrigger) return []; | ||
| 343 | if (composerTrigger.kind === "path") { | ||
| 344 | return (workspaceEntriesQuery.data?.entries ?? []) | ||
| 345 | .filter((entry) => !taggedPaths.includes(entry.path)) | ||
| 346 | .map((entry) => ({ | ||
| 347 | id: `path:${entry.kind}:${entry.path}`, | ||
| 348 | type: "path", | ||
| 349 | path: entry.path, | ||
| 350 | pathKind: entry.kind, | ||
| 351 | label: basenameOfPath(entry.path), | ||
| 352 | description: entry.parentPath ?? (entry.kind === "directory" ? "Folder" : "File"), | ||
| 353 | })); | ||
| 354 | } | ||
| 355 | |||
| 356 | if (composerTrigger.kind === "slash-command") { | ||
| 357 | if (!"model".includes(composerTrigger.query.toLowerCase())) { | ||
| 358 | return []; | ||
| 359 | } | ||
| 360 | return [ | ||
| 361 | { | ||
| 362 | id: "slash:model", | ||
| 363 | type: "slash-command", | ||
| 364 | label: "/model", | ||
| 365 | description: "Switch response model for this thread", | ||
| 366 | }, | ||
| 367 | ]; | ||
| 368 | } | ||
| 369 | |||
| 370 | return MODEL_OPTIONS.filter(({ slug, name }) => { | ||
| 371 | const query = composerTrigger.query.trim().toLowerCase(); | ||
| 372 | if (!query) return true; | ||
| 373 | return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query); | ||
| 374 | }).map(({ slug, name }) => ({ | ||
| 375 | id: `model:${slug}`, | ||
| 376 | type: "model", | ||
| 377 | model: slug, | ||
| 378 | label: name, | ||
| 379 | description: slug, | ||
| 380 | })); | ||
| 381 | }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]); | ||
| 382 | const composerMenuOpen = Boolean(composerTrigger); | ||
| 383 | const activeComposerMenuItem = | ||
| 384 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? | ||
| 385 | null; | ||
| 276 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | 386 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 277 | // Default true while loading to avoid toolbar flicker. | 387 | // Default true while loading to avoid toolbar flicker. |
| 278 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 388 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| ... | @@ -438,6 +548,10 @@ export default function ChatView() { | ... | @@ -438,6 +548,10 @@ export default function ChatView() { |
| 438 | setExpandedWorkGroups({}); | 548 | setExpandedWorkGroups({}); |
| 439 | }, [activeThread?.id]); | 549 | }, [activeThread?.id]); |
| 440 | 550 | ||
| 551 | useEffect(() => { | ||
| 552 | setComposerMenuIndex(0); | ||
| 553 | }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]); | ||
| 554 | |||
| 441 | useEffect(() => { | 555 | useEffect(() => { |
| 442 | if (!activeThread?.id || activeThread.terminalOpen) return; | 556 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| 443 | const frame = window.requestAnimationFrame(() => { | 557 | const frame = window.requestAnimationFrame(() => { |
| ... | @@ -457,6 +571,9 @@ export default function ChatView() { | ... | @@ -457,6 +571,9 @@ export default function ChatView() { |
| 457 | revokePreviewUrls(existing); | 571 | revokePreviewUrls(existing); |
| 458 | return []; | 572 | return []; |
| 459 | }); | 573 | }); |
| 574 | setTaggedPaths([]); | ||
| 575 | setComposerCursor(0); | ||
| 576 | setComposerMenuIndex(0); | ||
| 460 | dragDepthRef.current = 0; | 577 | dragDepthRef.current = 0; |
| 461 | setIsDragOverComposer(false); | 578 | setIsDragOverComposer(false); |
| 462 | setExpandedImage(null); | 579 | setExpandedImage(null); |
| ... | @@ -773,7 +890,10 @@ export default function ChatView() { | ... | @@ -773,7 +890,10 @@ export default function ChatView() { |
| 773 | e.preventDefault(); | 890 | e.preventDefault(); |
| 774 | if (!api || !activeThread || isSending || isConnecting) return; | 891 | if (!api || !activeThread || isSending || isConnecting) return; |
| 775 | const trimmed = prompt.trim(); | 892 | const trimmed = prompt.trim(); |
| 776 | if (!trimmed && composerImages.length === 0) return; | 893 | const taggedPathsSnapshot = [...taggedPaths]; |
| 894 | const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot); | ||
| 895 | const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot); | ||
| 896 | if (!promptForModel && composerImages.length === 0) return; | ||
| 777 | if (!activeProject) return; | 897 | if (!activeProject) return; |
| 778 | const composerImagesSnapshot = [...composerImages]; | 898 | const composerImagesSnapshot = [...composerImages]; |
| 779 | 899 | ||
| ... | @@ -813,6 +933,7 @@ export default function ChatView() { | ... | @@ -813,6 +933,7 @@ export default function ChatView() { |
| 813 | if (activeThread.messages.length === 0) { | 933 | if (activeThread.messages.length === 0) { |
| 814 | const titleSeed = | 934 | const titleSeed = |
| 815 | trimmed || | 935 | trimmed || |
| 936 | (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") || | ||
| 816 | (composerImagesSnapshot.length > 0 | 937 | (composerImagesSnapshot.length > 0 |
| 817 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` | 938 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 818 | : "New thread"); | 939 | : "New thread"); |
| ... | @@ -837,12 +958,15 @@ export default function ChatView() { | ... | @@ -837,12 +958,15 @@ export default function ChatView() { |
| 837 | type: "PUSH_USER_MESSAGE", | 958 | type: "PUSH_USER_MESSAGE", |
| 838 | threadId: activeThread.id, | 959 | threadId: activeThread.id, |
| 839 | id: crypto.randomUUID(), | 960 | id: crypto.randomUUID(), |
| 840 | text: trimmed, | 961 | text: userVisiblePrompt, |
| 841 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), | 962 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 842 | }); | 963 | }); |
| 843 | const previousMessages = activeThread.messages; | 964 | const previousMessages = activeThread.messages; |
| 844 | setPrompt(""); | 965 | setPrompt(""); |
| 845 | setComposerImages([]); | 966 | setComposerImages([]); |
| 967 | setTaggedPaths([]); | ||
| 968 | setComposerCursor(0); | ||
| 969 | setComposerMenuIndex(0); | ||
| 846 | 970 | ||
| 847 | const sessionInfo = await ensureSession(sessionCwd); | 971 | const sessionInfo = await ensureSession(sessionCwd); |
| 848 | if (!sessionInfo) return; | 972 | if (!sessionInfo) return; |
| ... | @@ -863,14 +987,14 @@ export default function ChatView() { | ... | @@ -863,14 +987,14 @@ export default function ChatView() { |
| 863 | const shouldBootstrap = | 987 | const shouldBootstrap = |
| 864 | previousMessages.length > 0 && | 988 | previousMessages.length > 0 && |
| 865 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); | 989 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); |
| 866 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; | 990 | const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT; |
| 867 | const input = shouldBootstrap | 991 | const input = shouldBootstrap |
| 868 | ? buildBootstrapInput( | 992 | ? buildBootstrapInput( |
| 869 | previousMessages, | 993 | previousMessages, |
| 870 | latestPromptForBootstrap, | 994 | latestPromptForBootstrap, |
| 871 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 995 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 872 | ).text | 996 | ).text |
| 873 | : trimmed || undefined; | 997 | : promptForModel || undefined; |
| 874 | await api.providers.sendTurn({ | 998 | await api.providers.sendTurn({ |
| 875 | sessionId: sessionInfo.sessionId, | 999 | sessionId: sessionInfo.sessionId, |
| 876 | ...(input ? { input } : {}), | 1000 | ...(input ? { input } : {}), |
| ... | @@ -949,7 +1073,74 @@ export default function ChatView() { | ... | @@ -949,7 +1073,74 @@ export default function ChatView() { |
| 949 | [scheduleComposerFocus], | 1073 | [scheduleComposerFocus], |
| 950 | ); | 1074 | ); |
| 951 | 1075 | ||
| 1076 | const applyPromptReplacement = useCallback( | ||
| 1077 | (rangeStart: number, rangeEnd: number, replacement: string) => { | ||
| 1078 | setPrompt((existing) => { | ||
| 1079 | const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement); | ||
| 1080 | window.requestAnimationFrame(() => { | ||
| 1081 | const textarea = textareaRef.current; | ||
| 1082 | if (!textarea) return; | ||
| 1083 | textarea.focus(); | ||
| 1084 | textarea.setSelectionRange(next.cursor, next.cursor); | ||
| 1085 | setComposerCursor(next.cursor); | ||
| 1086 | }); | ||
| 1087 | return next.text; | ||
| 1088 | }); | ||
| 1089 | }, | ||
| 1090 | [], | ||
| 1091 | ); | ||
| 1092 | |||
| 1093 | const onSelectComposerItem = useCallback( | ||
| 1094 | (item: ComposerCommandItem) => { | ||
| 1095 | if (!composerTrigger) return; | ||
| 1096 | if (item.type === "path") { | ||
| 1097 | setTaggedPaths((existing) => | ||
| 1098 | existing.includes(item.path) ? existing : [...existing, item.path], | ||
| 1099 | ); | ||
| 1100 | applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); | ||
| 1101 | return; | ||
| 1102 | } | ||
| 1103 | if (item.type === "slash-command") { | ||
| 1104 | applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model "); | ||
| 1105 | return; | ||
| 1106 | } | ||
| 1107 | onModelSelect(item.model); | ||
| 1108 | applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); | ||
| 1109 | }, | ||
| 1110 | [applyPromptReplacement, composerTrigger, onModelSelect], | ||
| 1111 | ); | ||
| 1112 | |||
| 1113 | const onRemoveTaggedPath = useCallback((pathToRemove: string) => { | ||
| 1114 | setTaggedPaths((existing) => existing.filter((entry) => entry !== pathToRemove)); | ||
| 1115 | }, []); | ||
| 1116 | |||
| 1117 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { | ||
| 1118 | setPrompt(nextPrompt); | ||
| 1119 | setComposerCursor(nextCursor); | ||
| 1120 | }, []); | ||
| 1121 | |||
| 952 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | 1122 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { |
| 1123 | if (composerMenuOpen && composerMenuItems.length > 0) { | ||
| 1124 | if (e.key === "ArrowDown") { | ||
| 1125 | e.preventDefault(); | ||
| 1126 | setComposerMenuIndex((existing) => Math.min(existing + 1, composerMenuItems.length - 1)); | ||
| 1127 | return; | ||
| 1128 | } | ||
| 1129 | if (e.key === "ArrowUp") { | ||
| 1130 | e.preventDefault(); | ||
| 1131 | setComposerMenuIndex((existing) => Math.max(existing - 1, 0)); | ||
| 1132 | return; | ||
| 1133 | } | ||
| 1134 | if (e.key === "Tab" || e.key === "Enter") { | ||
| 1135 | const selectedItem = activeComposerMenuItem ?? composerMenuItems[0]; | ||
| 1136 | if (selectedItem) { | ||
| 1137 | e.preventDefault(); | ||
| 1138 | onSelectComposerItem(selectedItem); | ||
| 1139 | return; | ||
| 1140 | } | ||
| 1141 | } | ||
| 1142 | } | ||
| 1143 | |||
| 953 | if (e.key === "Enter" && !e.shiftKey) { | 1144 | if (e.key === "Enter" && !e.shiftKey) { |
| 954 | e.preventDefault(); | 1145 | e.preventDefault(); |
| 955 | void onSend(e as unknown as FormEvent); | 1146 | void onSend(e as unknown as FormEvent); |
| ... | @@ -1045,7 +1236,81 @@ export default function ChatView() { | ... | @@ -1045,7 +1236,81 @@ export default function ChatView() { |
| 1045 | onDrop={onComposerDrop} | 1236 | onDrop={onComposerDrop} |
| 1046 | > | 1237 | > |
| 1047 | {/* Textarea area */} | 1238 | {/* Textarea area */} |
| 1048 | <div className="px-4 pt-4 pb-2"> | 1239 | <div className="relative px-4 pt-4 pb-2"> |
| 1240 | {composerMenuOpen && ( | ||
| 1241 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | ||
| 1242 | <Command> | ||
| 1243 | <CommandPanel className="rounded-xl border border-border/80 bg-popover/96 backdrop-blur-xs"> | ||
| 1244 | <CommandList className="max-h-64"> | ||
| 1245 | {composerMenuItems.map((item, index) => ( | ||
| 1246 | <CommandItem | ||
| 1247 | key={item.id} | ||
| 1248 | value={item.id} | ||
| 1249 | className={cn( | ||
| 1250 | "gap-2", | ||
| 1251 | index === composerMenuIndex && "bg-accent text-accent-foreground", | ||
| 1252 | )} | ||
| 1253 | onMouseEnter={() => setComposerMenuIndex(index)} | ||
| 1254 | onMouseDown={(event) => { | ||
| 1255 | event.preventDefault(); | ||
| 1256 | onSelectComposerItem(item); | ||
| 1257 | }} | ||
| 1258 | > | ||
| 1259 | {item.type === "path" ? ( | ||
| 1260 | item.pathKind === "directory" ? ( | ||
| 1261 | <FolderIcon className="size-4 text-muted-foreground/80" /> | ||
| 1262 | ) : ( | ||
| 1263 | <FileIcon className="size-4 text-muted-foreground/80" /> | ||
| 1264 | ) | ||
| 1265 | ) : null} | ||
| 1266 | {item.type === "slash-command" ? ( | ||
| 1267 | <CursorIcon className="size-4 text-muted-foreground/80" /> | ||
| 1268 | ) : null} | ||
| 1269 | {item.type === "model" ? ( | ||
| 1270 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | ||
| 1271 | model | ||
| 1272 | </Badge> | ||
| 1273 | ) : null} | ||
| 1274 | <span className="truncate">{item.label}</span> | ||
| 1275 | <span className="truncate text-muted-foreground/70 text-xs"> | ||
| 1276 | {item.description} | ||
| 1277 | </span> | ||
| 1278 | </CommandItem> | ||
| 1279 | ))} | ||
| 1280 | </CommandList> | ||
| 1281 | {composerMenuItems.length === 0 && ( | ||
| 1282 | <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | ||
| 1283 | {workspaceEntriesQuery.isLoading | ||
| 1284 | ? "Searching workspace files..." | ||
| 1285 | : composerTrigger?.kind === "path" | ||
| 1286 | ? "No matching files or folders." | ||
| 1287 | : "No matching command."} | ||
| 1288 | </p> | ||
| 1289 | )} | ||
| 1290 | </CommandPanel> | ||
| 1291 | </Command> | ||
| 1292 | </div> | ||
| 1293 | )} | ||
| 1294 | |||
| 1295 | {taggedPaths.length > 0 && ( | ||
| 1296 | <div className="mb-3 flex flex-wrap gap-1.5"> | ||
| 1297 | {taggedPaths.map((taggedPath) => ( | ||
| 1298 | <button | ||
| 1299 | key={taggedPath} | ||
| 1300 | type="button" | ||
| 1301 | className="inline-flex items-center gap-1 rounded-md border border-border/80 bg-secondary/65 px-2 py-1 text-[11px] text-foreground/85 transition-colors hover:bg-secondary" | ||
| 1302 | onClick={() => onRemoveTaggedPath(taggedPath)} | ||
| 1303 | aria-label={`Remove ${taggedPath}`} | ||
| 1304 | title={taggedPath} | ||
| 1305 | > | ||
| 1306 | <span className="text-muted-foreground/70">@</span> | ||
| 1307 | <span className="max-w-64 truncate">{taggedPath}</span> | ||
| 1308 | <XIcon className="size-3 text-muted-foreground/70" /> | ||
| 1309 | </button> | ||
| 1310 | ))} | ||
| 1311 | </div> | ||
| 1312 | )} | ||
| 1313 | |||
| 1049 | {composerImages.length > 0 && ( | 1314 | {composerImages.length > 0 && ( |
| 1050 | <div className="mb-3 flex flex-wrap gap-2"> | 1315 | <div className="mb-3 flex flex-wrap gap-2"> |
| 1051 | {composerImages.map((image) => ( | 1316 | {composerImages.map((image) => ( |
| ... | @@ -1085,13 +1350,33 @@ export default function ChatView() { | ... | @@ -1085,13 +1350,33 @@ export default function ChatView() { |
| 1085 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" | 1350 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" |
| 1086 | rows={2} | 1351 | rows={2} |
| 1087 | value={prompt} | 1352 | value={prompt} |
| 1088 | onChange={(e) => setPrompt(e.target.value)} | 1353 | onChange={(event) => |
| 1354 | onPromptChange( | ||
| 1355 | event.target.value, | ||
| 1356 | event.target.selectionStart ?? event.target.value.length, | ||
| 1357 | ) | ||
| 1358 | } | ||
| 1089 | onKeyDown={onKeyDown} | 1359 | onKeyDown={onKeyDown} |
| 1360 | onKeyUp={(event) => | ||
| 1361 | setComposerCursor( | ||
| 1362 | event.currentTarget.selectionStart ?? event.currentTarget.value.length, | ||
| 1363 | ) | ||
| 1364 | } | ||
| 1365 | onClick={(event) => | ||
| 1366 | setComposerCursor( | ||
| 1367 | event.currentTarget.selectionStart ?? event.currentTarget.value.length, | ||
| 1368 | ) | ||
| 1369 | } | ||
| 1370 | onSelect={(event) => | ||
| 1371 | setComposerCursor( | ||
| 1372 | event.currentTarget.selectionStart ?? event.currentTarget.value.length, | ||
| 1373 | ) | ||
| 1374 | } | ||
| 1090 | onPaste={onComposerPaste} | 1375 | onPaste={onComposerPaste} |
| 1091 | placeholder={ | 1376 | placeholder={ |
| 1092 | phase === "disconnected" | 1377 | phase === "disconnected" |
| 1093 | ? "Ask for follow-up changes or attach images" | 1378 | ? "Ask for follow-up changes or attach images" |
| 1094 | : "Ask anything, or attach images..." | 1379 | : "Ask anything, @tag files/folders, or use /model" |
| 1095 | } | 1380 | } |
| 1096 | disabled={isSending || isConnecting} | 1381 | disabled={isSending || isConnecting} |
| 1097 | /> | 1382 | /> |
| ... | @@ -1159,7 +1444,9 @@ export default function ChatView() { | ... | @@ -1159,7 +1444,9 @@ export default function ChatView() { |
| 1159 | type="submit" | 1444 | type="submit" |
| 1160 | className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" | 1445 | className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" |
| 1161 | disabled={ | 1446 | disabled={ |
| 1162 | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) | 1447 | isSending || |
| 1448 | isConnecting || | ||
| 1449 | (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0) | ||
| 1163 | } | 1450 | } |
| 1164 | aria-label={ | 1451 | aria-label={ |
| 1165 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" | 1452 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |
apps/web/src/composer-logic.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildPromptInput, | ||
| 5 | buildUserVisiblePrompt, | ||
| 6 | detectComposerTrigger, | ||
| 7 | replaceTextRange, | ||
| 8 | } from "./composer-logic"; | ||
| 9 | |||
| 10 | describe("detectComposerTrigger", () => { | ||
| 11 | it("detects @path trigger at cursor", () => { | ||
| 12 | const text = "Please check @src/com"; | ||
| 13 | const trigger = detectComposerTrigger(text, text.length); | ||
| 14 | |||
| 15 | expect(trigger).toEqual({ | ||
| 16 | kind: "path", | ||
| 17 | query: "src/com", | ||
| 18 | rangeStart: "Please check ".length, | ||
| 19 | rangeEnd: text.length, | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("detects slash command token while typing command name", () => { | ||
| 24 | const text = "/mo"; | ||
| 25 | const trigger = detectComposerTrigger(text, text.length); | ||
| 26 | |||
| 27 | expect(trigger).toEqual({ | ||
| 28 | kind: "slash-command", | ||
| 29 | query: "mo", | ||
| 30 | rangeStart: 0, | ||
| 31 | rangeEnd: text.length, | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("detects slash model query after /model", () => { | ||
| 36 | const text = "/model spark"; | ||
| 37 | const trigger = detectComposerTrigger(text, text.length); | ||
| 38 | |||
| 39 | expect(trigger).toEqual({ | ||
| 40 | kind: "slash-model", | ||
| 41 | query: "spark", | ||
| 42 | rangeStart: 0, | ||
| 43 | rangeEnd: text.length, | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | describe("replaceTextRange", () => { | ||
| 49 | it("replaces a text range and returns new cursor", () => { | ||
| 50 | const replaced = replaceTextRange("hello @src", 6, 10, ""); | ||
| 51 | expect(replaced).toEqual({ | ||
| 52 | text: "hello ", | ||
| 53 | cursor: 6, | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("tagged path prompt helpers", () => { | ||
| 59 | it("builds model input with tagged paths", () => { | ||
| 60 | const result = buildPromptInput("Fix this", ["src/app.tsx", "src/app.tsx", "README.md"]); | ||
| 61 | expect(result).toContain("Fix this"); | ||
| 62 | expect(result).toContain("Referenced paths:"); | ||
| 63 | expect(result).toContain("- src/app.tsx"); | ||
| 64 | expect(result).toContain("- README.md"); | ||
| 65 | expect(result).not.toContain("- src/app.tsx\n- src/app.tsx"); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("builds user-visible prompt from tags only", () => { | ||
| 69 | const result = buildUserVisiblePrompt(" ", ["src/app.tsx", "README.md"]); | ||
| 70 | expect(result).toBe("@src/app.tsx @README.md"); | ||
| 71 | }); | ||
| 72 | }); | ||
apps/web/src/composer-logic.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; | ||
| 2 | |||
| 3 | export interface ComposerTrigger { | ||
| 4 | kind: ComposerTriggerKind; | ||
| 5 | query: string; | ||
| 6 | rangeStart: number; | ||
| 7 | rangeEnd: number; | ||
| 8 | } | ||
| 9 | |||
| 10 | function clampCursor(text: string, cursor: number): number { | ||
| 11 | if (!Number.isFinite(cursor)) return text.length; | ||
| 12 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); | ||
| 13 | } | ||
| 14 | |||
| 15 | function isWhitespace(char: string): boolean { | ||
| 16 | return char === " " || char === "\n" || char === "\t" || char === "\r"; | ||
| 17 | } | ||
| 18 | |||
| 19 | function tokenStartForCursor(text: string, cursor: number): number { | ||
| 20 | let index = cursor - 1; | ||
| 21 | while (index >= 0 && !isWhitespace(text[index] ?? "")) { | ||
| 22 | index -= 1; | ||
| 23 | } | ||
| 24 | return index + 1; | ||
| 25 | } | ||
| 26 | |||
| 27 | function dedupePaths(paths: string[]): string[] { | ||
| 28 | const unique = new Set<string>(); | ||
| 29 | for (const rawPath of paths) { | ||
| 30 | const normalized = rawPath.trim(); | ||
| 31 | if (!normalized) continue; | ||
| 32 | unique.add(normalized); | ||
| 33 | } | ||
| 34 | return [...unique]; | ||
| 35 | } | ||
| 36 | |||
| 37 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { | ||
| 38 | const cursor = clampCursor(text, cursorInput); | ||
| 39 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | ||
| 40 | const linePrefix = text.slice(lineStart, cursor); | ||
| 41 | |||
| 42 | if (linePrefix.startsWith("/")) { | ||
| 43 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); | ||
| 44 | if (commandMatch) { | ||
| 45 | const commandQuery = commandMatch[1] ?? ""; | ||
| 46 | if (commandQuery.toLowerCase() === "model") { | ||
| 47 | return { | ||
| 48 | kind: "slash-model", | ||
| 49 | query: "", | ||
| 50 | rangeStart: lineStart, | ||
| 51 | rangeEnd: cursor, | ||
| 52 | }; | ||
| 53 | } | ||
| 54 | if ("model".startsWith(commandQuery.toLowerCase())) { | ||
| 55 | return { | ||
| 56 | kind: "slash-command", | ||
| 57 | query: commandQuery, | ||
| 58 | rangeStart: lineStart, | ||
| 59 | rangeEnd: cursor, | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | ||
| 66 | if (modelMatch) { | ||
| 67 | return { | ||
| 68 | kind: "slash-model", | ||
| 69 | query: (modelMatch[1] ?? "").trim(), | ||
| 70 | rangeStart: lineStart, | ||
| 71 | rangeEnd: cursor, | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | const tokenStart = tokenStartForCursor(text, cursor); | ||
| 77 | const token = text.slice(tokenStart, cursor); | ||
| 78 | if (!token.startsWith("@")) { | ||
| 79 | return null; | ||
| 80 | } | ||
| 81 | |||
| 82 | return { | ||
| 83 | kind: "path", | ||
| 84 | query: token.slice(1), | ||
| 85 | rangeStart: tokenStart, | ||
| 86 | rangeEnd: cursor, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function replaceTextRange( | ||
| 91 | text: string, | ||
| 92 | rangeStart: number, | ||
| 93 | rangeEnd: number, | ||
| 94 | replacement: string, | ||
| 95 | ): { text: string; cursor: number } { | ||
| 96 | const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | ||
| 97 | const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | ||
| 98 | const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | ||
| 99 | return { text: nextText, cursor: safeStart + replacement.length }; | ||
| 100 | } | ||
| 101 | |||
| 102 | export function buildPromptInput(text: string, taggedPaths: string[]): string { | ||
| 103 | const trimmedPrompt = text.trim(); | ||
| 104 | const normalizedPaths = dedupePaths(taggedPaths); | ||
| 105 | if (normalizedPaths.length === 0) { | ||
| 106 | return trimmedPrompt; | ||
| 107 | } | ||
| 108 | |||
| 109 | const tagBlock = `Referenced paths:\n${normalizedPaths.map((entry) => `- ${entry}`).join("\n")}`; | ||
| 110 | if (!trimmedPrompt) { | ||
| 111 | return tagBlock; | ||
| 112 | } | ||
| 113 | return `${trimmedPrompt}\n\n${tagBlock}`; | ||
| 114 | } | ||
| 115 | |||
| 116 | export function buildUserVisiblePrompt(text: string, taggedPaths: string[]): string { | ||
| 117 | const trimmedPrompt = text.trim(); | ||
| 118 | const normalizedPaths = dedupePaths(taggedPaths); | ||
| 119 | if (normalizedPaths.length === 0) { | ||
| 120 | return trimmedPrompt; | ||
| 121 | } | ||
| 122 | |||
| 123 | const tagsSummary = normalizedPaths.map((entry) => `@${entry}`).join(" "); | ||
| 124 | if (!trimmedPrompt) { | ||
| 125 | return tagsSummary; | ||
| 126 | } | ||
| 127 | return `${trimmedPrompt}\n\n${tagsSummary}`; | ||
| 128 | } | ||
apps/web/src/wsNativeApi.ts+1| ... | @@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi { |
| 98 | list: () => transport.request(WS_METHODS.projectsList), | 98 | list: () => transport.request(WS_METHODS.projectsList), |
| 99 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | 99 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), |
| 100 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | 100 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), |
| 101 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | ||
| 101 | }, | 102 | }, |
| 102 | shell: { | 103 | shell: { |
| 103 | openInEditor: (cwd, editor) => | 104 | openInEditor: (cwd, editor) => |
packages/contracts/src/ipc.ts+3| ... | @@ -29,6 +29,8 @@ import type { | ... | @@ -29,6 +29,8 @@ import type { |
| 29 | ProjectAddInput, | 29 | ProjectAddInput, |
| 30 | ProjectAddResult, | 30 | ProjectAddResult, |
| 31 | ProjectListResult, | 31 | ProjectListResult, |
| 32 | ProjectSearchEntriesInput, | ||
| 33 | ProjectSearchEntriesResult, | ||
| 32 | ProjectRemoveInput, | 34 | ProjectRemoveInput, |
| 33 | } from "./project"; | 35 | } from "./project"; |
| 34 | import type { ServerConfig } from "./server"; | 36 | import type { ServerConfig } from "./server"; |
| ... | @@ -90,6 +92,7 @@ export interface NativeApi { | ... | @@ -90,6 +92,7 @@ export interface NativeApi { |
| 90 | list: () => Promise<ProjectListResult>; | 92 | list: () => Promise<ProjectListResult>; |
| 91 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; | 93 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; |
| 92 | remove: (input: ProjectRemoveInput) => Promise<void>; | 94 | remove: (input: ProjectRemoveInput) => Promise<void>; |
| 95 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | ||
| 93 | }; | 96 | }; |
| 94 | shell: { | 97 | shell: { |
| 95 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | 98 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
packages/contracts/src/project.test.ts+36| ... | @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; | ... | @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | projectAddInputSchema, | 4 | projectAddInputSchema, |
| 5 | projectAddResultSchema, | 5 | projectAddResultSchema, |
| 6 | projectSearchEntriesInputSchema, | ||
| 7 | projectSearchEntriesResultSchema, | ||
| 6 | projectListResultSchema, | 8 | projectListResultSchema, |
| 7 | projectRemoveInputSchema, | 9 | projectRemoveInputSchema, |
| 8 | } from "./project"; | 10 | } from "./project"; |
| ... | @@ -46,4 +48,38 @@ describe("project contracts", () => { | ... | @@ -46,4 +48,38 @@ describe("project contracts", () => { |
| 46 | const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); | 48 | const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); |
| 47 | expect(parsed.id).toBe("project-1"); | 49 | expect(parsed.id).toBe("project-1"); |
| 48 | }); | 50 | }); |
| 51 | |||
| 52 | it("parses workspace entry search input with defaults", () => { | ||
| 53 | const parsed = projectSearchEntriesInputSchema.parse({ | ||
| 54 | cwd: " /tmp/project ", | ||
| 55 | query: " src ", | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(parsed).toEqual({ | ||
| 59 | cwd: "/tmp/project", | ||
| 60 | query: "src", | ||
| 61 | limit: 80, | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("parses workspace entry search result", () => { | ||
| 66 | const parsed = projectSearchEntriesResultSchema.parse({ | ||
| 67 | entries: [ | ||
| 68 | { | ||
| 69 | path: "src/components", | ||
| 70 | kind: "directory", | ||
| 71 | parentPath: "src", | ||
| 72 | }, | ||
| 73 | { | ||
| 74 | path: "src/components/Button.tsx", | ||
| 75 | kind: "file", | ||
| 76 | parentPath: "src/components", | ||
| 77 | }, | ||
| 78 | ], | ||
| 79 | truncated: false, | ||
| 80 | }); | ||
| 81 | |||
| 82 | expect(parsed.entries).toHaveLength(2); | ||
| 83 | expect(parsed.entries[0]?.kind).toBe("directory"); | ||
| 84 | }); | ||
| 49 | }); | 85 | }); |
packages/contracts/src/project.ts+25| ... | @@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({ | ... | @@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({ |
| 23 | id: z.string().min(1), | 23 | id: z.string().min(1), |
| 24 | }); | 24 | }); |
| 25 | 25 | ||
| 26 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; | ||
| 27 | |||
| 28 | export const projectSearchEntriesInputSchema = z.object({ | ||
| 29 | cwd: z.string().trim().min(1), | ||
| 30 | query: z.string().trim().max(256).default(""), | ||
| 31 | limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), | ||
| 32 | }); | ||
| 33 | |||
| 34 | export const projectEntryKindSchema = z.enum(["file", "directory"]); | ||
| 35 | |||
| 36 | export const projectEntrySchema = z.object({ | ||
| 37 | path: z.string().min(1), | ||
| 38 | kind: projectEntryKindSchema, | ||
| 39 | parentPath: z.string().optional(), | ||
| 40 | }); | ||
| 41 | |||
| 42 | export const projectSearchEntriesResultSchema = z.object({ | ||
| 43 | entries: z.array(projectEntrySchema), | ||
| 44 | truncated: z.boolean(), | ||
| 45 | }); | ||
| 46 | |||
| 26 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; | 47 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 27 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; | 48 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 28 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; | 49 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |
| 29 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; | 50 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; |
| 30 | export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>; | 51 | export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>; |
| 52 | export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>; | ||
| 53 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; | ||
| 54 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; | ||
| 55 | export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>; |
packages/contracts/src/ws.ts+1| ... | @@ -15,6 +15,7 @@ export const WS_METHODS = { | ... | @@ -15,6 +15,7 @@ export const WS_METHODS = { |
| 15 | projectsList: "projects.list", | 15 | projectsList: "projects.list", |
| 16 | projectsAdd: "projects.add", | 16 | projectsAdd: "projects.add", |
| 17 | projectsRemove: "projects.remove", | 17 | projectsRemove: "projects.remove", |
| 18 | projectsSearchEntries: "projects.searchEntries", | ||
| 18 | 19 | ||
| 19 | // Shell methods | 20 | // Shell methods |
| 20 | shellOpenInEditor: "shell.openInEditor", | 21 | shellOpenInEditor: "shell.openInEditor", |