From 3448cb427dcdaf148ad08934e9086e9aa56b6ce4 Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 13:59:28 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=9F=AC?= =?UTF-8?q?=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 354 ++++++++++++++++++++++++++++- package.json | 4 +- src/main/lib/searchExtract.test.ts | 86 +++++++ src/main/lib/searchExtract.ts | 37 +++ vitest.config.ts | 8 + 5 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 src/main/lib/searchExtract.test.ts create mode 100644 src/main/lib/searchExtract.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 861bd59..3fbc65d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -2652,6 +2653,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2721,6 +2729,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2730,6 +2749,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3137,6 +3163,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -3576,6 +3715,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3990,6 +4139,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5187,6 +5346,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -5683,6 +5849,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5693,6 +5869,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8796,6 +8982,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8976,6 +9176,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -9972,6 +10179,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -10053,6 +10267,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -10063,6 +10284,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10430,6 +10658,13 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -10457,6 +10692,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -11508,6 +11753,96 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -11627,6 +11962,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index c9657b1..3f0ae4e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", + "test": "vitest run", "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", @@ -52,6 +53,7 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.10" } } diff --git a/src/main/lib/searchExtract.test.ts b/src/main/lib/searchExtract.test.ts new file mode 100644 index 0000000..2d09d79 --- /dev/null +++ b/src/main/lib/searchExtract.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { Conversation, ConversationItem } from '../../shared/types' +import { extractSearchMessages } from './searchExtract' + +function conversation(items: ConversationItem[]): Conversation { + return { sessionId: 'session-1', items, sidechainCount: 0, hiddenCount: 0 } +} + +describe('extractSearchMessages', () => { + it('사용자 발화와 어시스턴트 텍스트를 uuid와 함께 뽑는다', () => { + const result = extractSearchMessages( + conversation([ + { + kind: 'user', + uuid: 'u1', + timestamp: null, + text: '검색 기능 만들자', + images: [], + meta: null + }, + { + kind: 'assistant', + uuid: 'a1', + timestamp: null, + blocks: [{ type: 'text', text: '인덱스를 먼저 만들죠' }] + } + ]) + ) + expect(result).toEqual([ + { ref: 'u1', role: 'user', text: '검색 기능 만들자' }, + { ref: 'a1', role: 'assistant', text: '인덱스를 먼저 만들죠' } + ]) + }) + + it('meta가 붙은 사용자 아이템은 발화가 아니므로 제외한다', () => { + const result = extractSearchMessages( + conversation([ + { + kind: 'user', + uuid: 'u1', + timestamp: null, + text: '', + images: [], + meta: { kind: 'command', label: 'model', detail: '출력' } + } + ]) + ) + expect(result).toEqual([]) + }) + + it('사고 과정과 도구 호출은 제외하고 텍스트 블록만 이어 붙인다', () => { + const result = extractSearchMessages( + conversation([ + { + kind: 'assistant', + uuid: 'a1', + timestamp: null, + blocks: [ + { type: 'thinking', text: '내부 독백' }, + { type: 'text', text: '첫 문단' }, + { + type: 'toolCall', + id: 't1', + name: 'Read', + input: null, + result: '파일 내용', + isError: false + }, + { type: 'text', text: '둘째 문단' } + ] + } + ]) + ) + expect(result).toEqual([{ ref: 'a1', role: 'assistant', text: '첫 문단\n둘째 문단' }]) + }) + + it('공백뿐인 아이템은 버린다', () => { + const result = extractSearchMessages( + conversation([ + { kind: 'user', uuid: 'u1', timestamp: null, text: ' \n ', images: [], meta: null }, + { kind: 'assistant', uuid: 'a1', timestamp: null, blocks: [] } + ]) + ) + expect(result).toEqual([]) + }) +}) diff --git a/src/main/lib/searchExtract.ts b/src/main/lib/searchExtract.ts new file mode 100644 index 0000000..a34c0eb --- /dev/null +++ b/src/main/lib/searchExtract.ts @@ -0,0 +1,37 @@ +import type { Conversation } from '../../shared/types' + +export interface SearchMessage { + /** 매칭된 ConversationItem의 uuid — 검색 결과에서 이 아이템으로 이동하는 데 쓴다 */ + ref: string + role: 'user' | 'assistant' + text: string +} + +/** + * 대화에서 검색 대상 텍스트만 뽑는다. + * + * 도구 호출·결과는 원본 JSONL의 99%를 차지해 결과를 덮어버리고, 사고 과정은 모델 내부 + * 독백이라 뺀다. meta가 붙은 사용자 아이템(커맨드 출력·주입 컨텍스트·셸 실행 등)도 + * 사용자가 한 말이 아니므로 제외한다. + * + * JSONL을 직접 읽지 않고 파서 출력에서 뽑는 이유: 연속 assistant 엔트리를 파서가 한 + * 아이템으로 병합하므로, 엔트리 uuid를 그대로 쓰면 화면에 없는 아이템을 가리키게 된다. + */ +export function extractSearchMessages(conversation: Conversation): SearchMessage[] { + const messages: SearchMessage[] = [] + for (const item of conversation.items) { + if (item.kind === 'user') { + if (item.meta !== null) continue + const text = item.text.trim() + if (text) messages.push({ ref: item.uuid, role: 'user', text }) + continue + } + const texts: string[] = [] + for (const block of item.blocks) { + if (block.type === 'text') texts.push(block.text) + } + const text = texts.join('\n').trim() + if (text) messages.push({ ref: item.uuid, role: 'assistant', text }) + } + return messages +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..230cfb7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node' + } +}) From 12826e133ca431cc8a51a44934666f8880b30930 Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 14:00:26 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=A7=A4=EC=B9=AD=EA=B3=BC=20=EC=8A=A4=EB=8B=88?= =?UTF-8?q?=ED=8E=AB=20=EC=A0=88=EB=8B=A8=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/lib/searchMatch.test.ts | 92 ++++++++++++++++++++++++++++++++ src/main/lib/searchMatch.ts | 89 ++++++++++++++++++++++++++++++ src/shared/types.ts | 23 ++++++++ 3 files changed, 204 insertions(+) create mode 100644 src/main/lib/searchMatch.test.ts create mode 100644 src/main/lib/searchMatch.ts diff --git a/src/main/lib/searchMatch.test.ts b/src/main/lib/searchMatch.test.ts new file mode 100644 index 0000000..27171fd --- /dev/null +++ b/src/main/lib/searchMatch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { SearchMessage } from './searchExtract' +import { indexMessages, matchDocument, type SearchDocument } from './searchMatch' + +function document(messages: SearchMessage[]): SearchDocument { + return { + sessionId: 'session-1', + projectId: 'project-1', + filePath: '/tmp/session-1.jsonl', + title: '세션 제목', + updatedAt: 1_700_000_000_000, + fileSize: 1024, + messages: indexMessages(messages) + } +} + +describe('matchDocument', () => { + it('매칭이 없으면 null을 돌려준다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '고래' }]), '상어', 5) + expect(result).toBeNull() + }) + + it('빈 질의는 매칭으로 치지 않는다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '고래' }]), '', 5) + expect(result).toBeNull() + }) + + it('대소문자를 무시하고 찾으며 원문 대소문자로 스니펫을 만든다', () => { + const result = matchDocument( + document([{ ref: 'u1', role: 'user', text: 'SearchIndex 를 만들자' }]), + 'searchindex', + 5 + ) + expect(result?.matchCount).toBe(1) + expect(result?.snippets[0].match).toBe('SearchIndex') + }) + + it('한 문서 안의 모든 매칭을 센다', () => { + const result = matchDocument( + document([ + { ref: 'u1', role: 'user', text: '검색 검색' }, + { ref: 'a1', role: 'assistant', text: '검색이요' } + ]), + '검색', + 5 + ) + expect(result?.matchCount).toBe(3) + }) + + it('스니펫 수는 상한을 넘지 않지만 matchCount는 전체를 센다', () => { + const result = matchDocument( + document([{ ref: 'u1', role: 'user', text: '가 가 가 가 가' }]), + '가', + 2 + ) + expect(result?.matchCount).toBe(5) + expect(result?.snippets).toHaveLength(2) + }) + + it('긴 텍스트는 앞뒤를 잘라 말줄임표를 붙이고 개행을 눌러 준다', () => { + const long = `${'앞'.repeat(200)}\n표적\n${'뒤'.repeat(200)}` + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: long }]), '표적', 5) + const snippet = result?.snippets[0] + expect(snippet?.before.startsWith('…')).toBe(true) + expect(snippet?.after.endsWith('…')).toBe(true) + expect(snippet?.before).not.toContain('\n') + expect(snippet?.after).not.toContain('\n') + expect(snippet?.match).toBe('표적') + }) + + it('짧은 텍스트에는 말줄임표를 붙이지 않는다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '표적' }]), '표적', 5) + expect(result?.snippets[0]).toEqual({ + ref: 'u1', + role: 'user', + before: '', + match: '표적', + after: '' + }) + }) + + it('세션 식별 정보를 그대로 옮긴다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '표적' }]), '표적', 5) + expect(result).toMatchObject({ + sessionId: 'session-1', + projectId: 'project-1', + filePath: '/tmp/session-1.jsonl', + title: '세션 제목', + updatedAt: 1_700_000_000_000 + }) + }) +}) diff --git a/src/main/lib/searchMatch.ts b/src/main/lib/searchMatch.ts new file mode 100644 index 0000000..f190d2d --- /dev/null +++ b/src/main/lib/searchMatch.ts @@ -0,0 +1,89 @@ +import type { SearchHit, SearchSnippet } from '../../shared/types' +import type { SearchMessage } from './searchExtract' + +/** 스니펫에서 매칭 앞뒤로 남길 문자 수 */ +const CONTEXT_BEFORE = 50 +const CONTEXT_AFTER = 110 + +export interface IndexedMessage extends SearchMessage { + /** text의 소문자 사본 — 검색마다 코퍼스 전체를 다시 변환하지 않으려고 적재 시 한 번 만든다 */ + lower: string +} + +export interface SearchDocument { + sessionId: string + projectId: string + filePath: string + title: string + updatedAt: number + fileSize: number + messages: IndexedMessage[] +} + +export function indexMessages(messages: SearchMessage[]): IndexedMessage[] { + return messages.map((message) => ({ ...message, lower: message.text.toLowerCase() })) +} + +/** 여러 줄을 한 줄로 눌러 스니펫 한 행에 담기게 한다 */ +function flatten(text: string): string { + return text.replace(/\s+/g, ' ') +} + +export function buildSnippet( + message: SearchMessage, + matchIndex: number, + matchLength: number +): SearchSnippet { + const start = Math.max(0, matchIndex - CONTEXT_BEFORE) + const end = Math.min(message.text.length, matchIndex + matchLength + CONTEXT_AFTER) + const head = start > 0 ? '…' : '' + const tail = end < message.text.length ? '…' : '' + return { + ref: message.ref, + role: message.role, + before: head + flatten(message.text.slice(start, matchIndex)), + match: flatten(message.text.slice(matchIndex, matchIndex + matchLength)), + after: flatten(message.text.slice(matchIndex + matchLength, end)) + tail + } +} + +/** + * 문서 하나에서 질의를 모두 찾는다. 매칭이 없으면 null. + * lowered는 이미 소문자로 만들어 넘겨야 한다. + * + * 소문자 변환은 사실상 모든 문자에서 길이를 보존하므로, lower에서 얻은 인덱스를 원문 + * 슬라이스에 그대로 쓴다. + */ +export function matchDocument( + document: SearchDocument, + lowered: string, + maxSnippets: number +): SearchHit | null { + // 빈 질의는 indexOf가 매번 0을 돌려줘 무한 루프가 된다 + if (!lowered) return null + + let matchCount = 0 + const snippets: SearchSnippet[] = [] + for (const message of document.messages) { + let from = 0 + for (;;) { + const index = message.lower.indexOf(lowered, from) + if (index === -1) break + matchCount += 1 + if (snippets.length < maxSnippets) { + snippets.push(buildSnippet(message, index, lowered.length)) + } + from = index + lowered.length + } + } + if (matchCount === 0) return null + return { + sessionId: document.sessionId, + projectId: document.projectId, + filePath: document.filePath, + title: document.title, + updatedAt: document.updatedAt, + matchCount, + snippets + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 83c056b..e0ab87b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -133,6 +133,29 @@ export type UpdateEvent = | { type: 'ready'; version: string } | { type: 'error'; message: string } +export interface SearchSnippet { + /** 매칭된 ConversationItem의 uuid */ + ref: string + role: 'user' | 'assistant' + /** 매칭 앞 문맥 — 잘렸으면 앞에 말줄임표가 붙는다 */ + before: string + /** 원문 대소문자를 보존한 매칭 부분 */ + match: string + /** 매칭 뒤 문맥 — 잘렸으면 뒤에 말줄임표가 붙는다 */ + after: string +} + +export interface SearchHit { + sessionId: string + projectId: string + filePath: string + title: string + updatedAt: number + /** 세션 전체 매칭 수 — snippets 길이보다 클 수 있다 */ + matchCount: number + snippets: SearchSnippet[] +} + export interface ClaudeHistoryApi { listProjects: () => Promise listSessions: (projectId: string) => Promise From 28511b6b81f993d1d6cd4c9b9834405a690e872b Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 14:01:33 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=A0=81=EC=9E=AC?= =?UTF-8?q?=C2=B7=EC=A0=95=ED=95=A9=C2=B7=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/lib/searchIndex.ts | 229 ++++++++++++++++++++++++++++++++++++ src/shared/types.ts | 18 +++ 2 files changed, 247 insertions(+) create mode 100644 src/main/lib/searchIndex.ts diff --git a/src/main/lib/searchIndex.ts b/src/main/lib/searchIndex.ts new file mode 100644 index 0000000..992b81d --- /dev/null +++ b/src/main/lib/searchIndex.ts @@ -0,0 +1,229 @@ +import { mkdir, readdir, readFile, rename, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { app, type BrowserWindow } from 'electron' +import type { SearchHit, SearchProgress, SearchResults, SessionMeta } from '../../shared/types' +import { listSessions, projectsRoot } from './scanner' +import { parseConversation } from './parser' +import { extractSearchMessages, type SearchMessage } from './searchExtract' +import { indexMessages, matchDocument, type SearchDocument } from './searchMatch' + +/** 스키마가 바뀌면 올린다 — 값이 다르면 저장된 인덱스를 버리고 다시 만든다 */ +const INDEX_VERSION = 1 +const MAX_SNIPPETS_PER_SESSION = 5 +const MAX_HITS = 200 +/** 검색이 들어올 때마다 전체 정합을 돌리지 않도록 두는 최소 간격 */ +const RECONCILE_INTERVAL_MS = 5000 +/** 첫 화면 로딩과 CPU를 다투지 않도록 인덱싱 시작을 미루는 시간 */ +const INDEX_START_DELAY_MS = 1500 + +/** 디스크에 저장하는 형태 — 소문자 사본은 적재할 때 다시 만든다 */ +interface StoredDocument { + sessionId: string + projectId: string + filePath: string + title: string + updatedAt: number + fileSize: number + messages: SearchMessage[] +} + +const documents = new Map() +let ready = false +let progress: SearchProgress = { done: 0, total: 0, ready: false } +let running: Promise | null = null +let lastReconcileAt = 0 +let progressWindow: BrowserWindow | null = null + +function indexPath(): string { + return join(app.getPath('userData'), 'search-index.jsonl') +} + +function emitProgress(): void { + if (progressWindow && !progressWindow.isDestroyed()) { + progressWindow.webContents.send('search:progress', progress) + } +} + +function isStoredDocument(value: unknown): value is StoredDocument { + const stored = value as StoredDocument | null + return ( + !!stored && + typeof stored.sessionId === 'string' && + typeof stored.projectId === 'string' && + typeof stored.filePath === 'string' && + typeof stored.title === 'string' && + typeof stored.updatedAt === 'number' && + typeof stored.fileSize === 'number' && + Array.isArray(stored.messages) + ) +} + +async function loadFromDisk(): Promise { + const raw = await readFile(indexPath(), 'utf8').catch(() => '') + if (!raw) return + const lines = raw.split('\n') + let version: unknown + try { + version = (JSON.parse(lines[0]) as { v?: unknown }).v + } catch { + // 헤더를 읽을 수 없으면 인덱스를 통째로 버리고 다시 만든다 + return + } + if (version !== INDEX_VERSION) return + for (const line of lines.slice(1)) { + if (!line.trim()) continue + try { + const stored: unknown = JSON.parse(line) + if (!isStoredDocument(stored)) continue + documents.set(stored.filePath, { ...stored, messages: indexMessages(stored.messages) }) + } catch { + // 줄 하나가 깨져도 나머지는 살린다 (forEachJsonlLine과 같은 관용) + continue + } + } +} + +async function persist(): Promise { + const path = indexPath() + const temporary = `${path}.tmp` + const lines = [JSON.stringify({ v: INDEX_VERSION })] + for (const document of documents.values()) { + const stored: StoredDocument = { + sessionId: document.sessionId, + projectId: document.projectId, + filePath: document.filePath, + title: document.title, + updatedAt: document.updatedAt, + fileSize: document.fileSize, + messages: document.messages.map(({ ref, role, text }) => ({ ref, role, text })) + } + lines.push(JSON.stringify(stored)) + } + await mkdir(dirname(path), { recursive: true }) + // 부분 기록된 인덱스를 남기지 않도록 임시 파일에 쓰고 원자적으로 바꿔 끼운다 + await writeFile(temporary, lines.join('\n')) + await rename(temporary, path) +} + +async function listProjectIds(): Promise { + const dirents = await readdir(projectsRoot(), { withFileTypes: true }).catch(() => []) + return dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name) +} + +async function buildDocument(meta: SessionMeta): Promise { + // 세션 하나가 깨져도 나머지 인덱싱은 계속한다 + const conversation = await parseConversation(meta.filePath).catch(() => null) + if (!conversation) return null + const messages = extractSearchMessages(conversation) + if (messages.length === 0) return null + return { + sessionId: meta.id, + projectId: meta.projectId, + filePath: meta.filePath, + title: meta.title, + updatedAt: meta.updatedAt, + fileSize: meta.fileSize, + messages: indexMessages(messages) + } +} + +async function reconcile(): Promise { + const projectIds = await listProjectIds() + progress = { done: 0, total: projectIds.length, ready } + emitProgress() + + const seen = new Set() + let changed = false + + for (const projectId of projectIds) { + const metas = await listSessions(projectId).catch(() => [] as SessionMeta[]) + for (const meta of metas) { + seen.add(meta.filePath) + const existing = documents.get(meta.filePath) + // updatedAt(마지막 엔트리 시각)과 fileSize가 모두 같으면 내용이 그대로다 + if ( + existing && + existing.updatedAt === meta.updatedAt && + existing.fileSize === meta.fileSize + ) { + continue + } + const document = await buildDocument(meta) + if (document) documents.set(meta.filePath, document) + else documents.delete(meta.filePath) + changed = true + } + progress = { done: progress.done + 1, total: projectIds.length, ready } + emitProgress() + // 인덱싱이 렌더러의 IPC를 굶기지 않도록 프로젝트 사이에서 이벤트 루프를 넘긴다 + await new Promise((resolve) => setImmediate(resolve)) + } + + for (const filePath of [...documents.keys()]) { + if (seen.has(filePath)) continue + documents.delete(filePath) + changed = true + } + + ready = true + lastReconcileAt = Date.now() + progress = { ...progress, ready: true } + emitProgress() + if (changed) { + await persist().catch((error) => console.error('[search] 인덱스 저장 실패', error)) + } +} + +function ensureIndex(): Promise { + if (!running) { + running = (async () => { + try { + if (!ready) await loadFromDisk() + await reconcile() + } catch (error) { + // 정합 내부는 파일 단위로 이미 방어돼 있다. 여기까지 온 예외는 검색을 영영 + // "인덱싱 중"에 묶어두지 않도록 준비 완료로 표시하고 다음 요청에서 다시 시도한다. + console.error('[search] 인덱싱 실패', error) + ready = true + lastReconcileAt = Date.now() + progress = { ...progress, ready: true } + emitProgress() + } + })().finally(() => { + running = null + }) + } + return running +} + +export function initSearchIndex(window: BrowserWindow): void { + progressWindow = window + setTimeout(() => void ensureIndex(), INDEX_START_DELAY_MS) +} + +export async function searchSessions(query: string): Promise { + const trimmed = query.trim() + if (!trimmed) return { query: trimmed, hits: [], truncated: false, indexing: !ready } + + if (!ready) { + // 구축 중이면 기다리지 않고 지금까지 만들어진 만큼으로 답한다 + void ensureIndex() + } else if (Date.now() - lastReconcileAt > RECONCILE_INTERVAL_MS) { + await ensureIndex() + } + + const lowered = trimmed.toLowerCase() + const hits: SearchHit[] = [] + for (const document of documents.values()) { + const hit = matchDocument(document, lowered, MAX_SNIPPETS_PER_SESSION) + if (hit) hits.push(hit) + } + hits.sort((a, b) => b.updatedAt - a.updatedAt) + + return { + query: trimmed, + hits: hits.slice(0, MAX_HITS), + truncated: hits.length > MAX_HITS, + indexing: !ready + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e0ab87b..ef8b130 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -156,6 +156,24 @@ export interface SearchHit { snippets: SearchSnippet[] } +export interface SearchResults { + query: string + hits: SearchHit[] + /** 세션 수 상한에 걸려 잘렸으면 true */ + truncated: boolean + /** 최초 인덱스 구축 중이라 부분 결과면 true */ + indexing: boolean +} + +export interface SearchProgress { + /** 처리한 프로젝트 수 */ + done: number + /** 전체 프로젝트 수 — 집계 전이면 0 */ + total: number + /** 최초 인덱스 구축이 끝났으면 true */ + ready: boolean +} + export interface ClaudeHistoryApi { listProjects: () => Promise listSessions: (projectId: string) => Promise From 492a3fc869cd0bea7e04076ffac0b48cab5e0b5e Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 14:02:23 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20IPC=20=EC=B1=84=EB=84=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 5 +++++ src/preload/index.ts | 9 ++++++++- src/shared/types.ts | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 9a04859..bda3e75 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { listProjects, listSessions } from './lib/scanner' import { parseConversation } from './lib/parser' +import { initSearchIndex, searchSessions } from './lib/searchIndex' import { deleteSession, forkSession, resumeSession, revealSession } from './lib/actions' import { loadSettings, saveSettings } from './lib/settings' import { listTerminals } from './lib/terminals' @@ -25,6 +26,9 @@ function registerIpcHandlers(): void { ipcMain.handle('projects:list', () => listProjects()) ipcMain.handle('sessions:list', (_event, projectId: string) => listSessions(projectId)) ipcMain.handle('conversation:load', (_event, filePath: string) => parseConversation(filePath)) + ipcMain.handle('search:query', (_event, query: string) => + searchSessions(typeof query === 'string' ? query : '') + ) ipcMain.handle('session:resume', (_event, sessionId: string, cwd: string | null) => resumeSession(sessionId, cwd) ) @@ -130,6 +134,7 @@ function createWindow(): void { }) initAutoUpdate(mainWindow) + initSearchIndex(mainWindow) mainWindow.on('ready-to-show', () => { mainWindow.show() diff --git a/src/preload/index.ts b/src/preload/index.ts index 3e6b2d7..461c8e5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,10 +1,11 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { ClaudeHistoryApi, UpdateEvent } from '../shared/types' +import type { ClaudeHistoryApi, SearchProgress, UpdateEvent } from '../shared/types' const api: ClaudeHistoryApi = { listProjects: () => ipcRenderer.invoke('projects:list'), listSessions: (projectId) => ipcRenderer.invoke('sessions:list', projectId), loadConversation: (filePath) => ipcRenderer.invoke('conversation:load', filePath), + searchSessions: (query) => ipcRenderer.invoke('search:query', query), resumeSession: (sessionId, cwd) => ipcRenderer.invoke('session:resume', sessionId, cwd), forkSession: (sessionId, cwd) => ipcRenderer.invoke('session:fork', sessionId, cwd), deleteSession: (filePath) => ipcRenderer.invoke('session:delete', filePath), @@ -24,6 +25,12 @@ const api: ClaudeHistoryApi = { ipcRenderer.on('menu:open-settings', listener) return () => ipcRenderer.removeListener('menu:open-settings', listener) }, + onSearchProgress: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, payload: SearchProgress): void => + callback(payload) + ipcRenderer.on('search:progress', listener) + return () => ipcRenderer.removeListener('search:progress', listener) + }, showSessionMenu: (labels) => ipcRenderer.invoke('session:menu', labels), installUpdate: () => ipcRenderer.invoke('update:install') } diff --git a/src/shared/types.ts b/src/shared/types.ts index ef8b130..e44e702 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -178,6 +178,7 @@ export interface ClaudeHistoryApi { listProjects: () => Promise listSessions: (projectId: string) => Promise loadConversation: (filePath: string) => Promise + searchSessions: (query: string) => Promise resumeSession: (sessionId: string, cwd: string | null) => Promise forkSession: (sessionId: string, cwd: string | null) => Promise deleteSession: (filePath: string) => Promise @@ -188,6 +189,7 @@ export interface ClaudeHistoryApi { checkForUpdate: (force?: boolean) => Promise onUpdateEvent: (callback: (event: UpdateEvent) => void) => () => void onOpenSettings: (callback: () => void) => () => void + onSearchProgress: (callback: (progress: SearchProgress) => void) => () => void showSessionMenu: (labels: { reveal: string delete: string From 5a557a105188f88c40203adc92d254f67461de67 Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 14:03:44 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/components/SearchView.tsx | 113 +++++++++++++ src/renderer/src/i18n.ts | 32 +++- src/renderer/src/styles/app.css | 184 +++++++++++++++++++++ 3 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/SearchView.tsx diff --git a/src/renderer/src/components/SearchView.tsx b/src/renderer/src/components/SearchView.tsx new file mode 100644 index 0000000..7f7b736 --- /dev/null +++ b/src/renderer/src/components/SearchView.tsx @@ -0,0 +1,113 @@ +import type { ReactElement, RefObject } from 'react' +import type { SearchHit, SearchProgress, SearchResults } from '../../../shared/types' +import { formatRelativeTime } from '../lib/format' +import { usePrefs } from '../prefs' +import { SidebarExpand } from './SidebarExpand' + +interface Props { + query: string + results: SearchResults | null + progress: SearchProgress | null + inputRef: RefObject + /** projectId를 사이드바에 보이는 이름으로 옮긴다 */ + projectLabel: (projectId: string) => string + onQueryChange: (query: string) => void + onOpenHit: (hit: SearchHit, ref: string) => void + onClose: () => void + onExpandSidebar: (() => void) | null +} + +export function SearchView({ + query, + results, + progress, + inputRef, + projectLabel, + onQueryChange, + onOpenHit, + onClose, + onExpandSidebar +}: Props): ReactElement { + const { t } = usePrefs() + const trimmed = query.trim() + const indexing = progress ? !progress.ready : false + + let status = '' + if (indexing) { + status = + progress && progress.total > 0 + ? t('search.indexing', { done: progress.done, total: progress.total }) + : t('search.indexingPrep') + } else if (!trimmed) { + status = t('search.hint') + } else if (results && results.hits.length === 0) { + status = t('search.empty', { query: trimmed }) + } else if (results) { + const matches = results.hits.reduce((sum, hit) => sum + hit.matchCount, 0) + status = t('search.summary', { sessions: results.hits.length, matches }) + } + + return ( +
+
+ {onExpandSidebar && } + onQueryChange(event.target.value)} + spellCheck={false} + autoFocus + /> + +
+

{status}

+
+ {results?.hits.map((hit) => ( +
+ +
    + {hit.snippets.map((snippet, index) => ( +
  • + +
  • + ))} + {hit.matchCount > hit.snippets.length && ( +
  • + {t('search.more', { n: hit.matchCount - hit.snippets.length })} +
  • + )} +
+
+ ))} + {results?.truncated && ( +

+ {t('search.truncated', { n: results.hits.length })} +

+ )} +
+
+ ) +} diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index fc6c6ba..f91b030 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -8,11 +8,25 @@ const ko = { 'sidebar.empty': '~/.claude/projects 에서\n세션을 찾지 못했습니다.', 'sidebar.collapse': '사이드바 접기', 'sidebar.expand': '사이드바 펼치기', + 'sidebar.contentSearch': '대화 내용 검색 (⌘⇧F)', + 'search.placeholder': '모든 대화 내용에서 검색', + 'search.close': '닫기', + 'search.hint': '기억나는 단어를 입력하세요. 모든 프로젝트의 대화 본문을 찾습니다.', + 'search.indexing': '인덱싱 중… {done}/{total}', + 'search.indexingPrep': '인덱싱 준비 중…', + 'search.empty': '"{query}" 와 일치하는 대화가 없습니다', + 'search.summary': '세션 {sessions}개 · 매칭 {matches}건', + 'search.truncated': '결과가 많아 최근 {n}개 세션만 표시합니다', + 'search.matches': '매칭 {n}', + 'search.more': '이 세션에 {n}건 더', + 'search.role.user': '나', + 'search.role.assistant': 'Claude', + 'search.missing': '세션 파일을 찾을 수 없습니다', 'menu.reveal': 'Finder에서 보기', 'menu.delete': '삭제…', 'session.messages': '메시지 {n}', 'empty.title': '왼쪽에서 세션을 선택하세요', - 'empty.hint': '⌘F 검색 · 클릭으로 열람', + 'empty.hint': '⌘F 세션 검색 · ⌘⇧F 대화 내용 검색', 'action.resume': '터미널에서 이어가기', 'action.resume.hint': '터미널에서 이 세션을 이어서 연다', 'action.fork': 'Fork로 열기', @@ -94,11 +108,25 @@ const en: Record = { 'sidebar.empty': 'No sessions found in\n~/.claude/projects.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', + 'sidebar.contentSearch': 'Search message contents (⌘⇧F)', + 'search.placeholder': 'Search across all conversations', + 'search.close': 'Close', + 'search.hint': 'Type a word you remember. Wake searches message contents across every project.', + 'search.indexing': 'Indexing… {done}/{total}', + 'search.indexingPrep': 'Preparing index…', + 'search.empty': 'No conversations match "{query}"', + 'search.summary': '{sessions} sessions · {matches} matches', + 'search.truncated': 'Too many results — showing the {n} most recent sessions', + 'search.matches': '{n} matches', + 'search.more': '{n} more in this session', + 'search.role.user': 'You', + 'search.role.assistant': 'Claude', + 'search.missing': 'Session file not found', 'menu.reveal': 'Reveal in Finder', 'menu.delete': 'Delete…', 'session.messages': '{n} messages', 'empty.title': 'Select a session from the sidebar', - 'empty.hint': '⌘F to search · click to read', + 'empty.hint': '⌘F to filter sessions · ⌘⇧F to search contents', 'action.resume': 'Resume in terminal', 'action.resume.hint': 'Reopen this session in a terminal', 'action.fork': 'Open as fork', diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 403b68b..09fa3f0 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1500,3 +1500,187 @@ body.is-resizing { .app[data-font-scale='large'] .user-message__text { font-size: 13.8px; } + +/* ── 사이드바 내용 검색 진입 버튼 ────────────────────────────── */ +.sidebar__search-row { + display: flex; + gap: 6px; + align-items: center; +} + +.sidebar__content-search { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 31px; + height: 31px; + color: var(--text-3); + background: var(--surface); + border: 1px solid var(--border-soft); + border-radius: 7px; +} + +.sidebar__content-search:hover { + color: var(--accent); + border-color: var(--accent); +} + +/* ── 대화 내용 검색 화면 ─────────────────────────────────────── */ +/* 헤더는 conversation__header와 같은 타이틀바 보정을 쓴다 (macOS hiddenInset 신호등 자리) */ +.search-view__header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 28px 14px 24px; + padding-top: calc(var(--titlebar-height) - 30px + 14px); + border-bottom: 1px solid var(--border-soft); + -webkit-app-region: drag; +} + +.app.is-sidebar-collapsed .search-view__header { + padding-left: 84px; /* macOS 신호등 버튼 자리 */ +} + +.search-view__header input, +.search-view__header button { + -webkit-app-region: no-drag; +} + +.search-view__input { + flex: 1; + min-width: 0; + font-family: var(--font-ui); + font-size: 14px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 13px; +} + +.search-view__input::placeholder { + color: var(--text-3); +} + +.search-view__input:focus { + outline: none; + border-color: var(--accent); +} + +.search-view__status { + padding: 14px clamp(20px, 4vw, 40px) 0; + font-size: 12px; + color: var(--text-3); +} + +.search-view__results { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 14px clamp(20px, 4vw, 40px) 80px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.search-hit { + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--bg-raised); + overflow: hidden; +} + +.search-hit__head { + width: 100%; + display: flex; + flex-direction: column; + gap: 3px; + align-items: flex-start; + text-align: left; + padding: 11px 14px; + border-bottom: 1px solid var(--border-soft); +} + +.search-hit__head:hover { + background: var(--surface-hover); +} + +.search-hit__title { + font-size: 13.5px; + font-weight: 600; + color: var(--text); +} + +.search-hit__meta { + font-size: 11px; + color: var(--text-3); +} + +.search-hit__snippets { + list-style: none; +} + +.snippet { + width: 100%; + display: flex; + gap: 10px; + align-items: baseline; + text-align: left; + padding: 9px 14px; + font-size: 12.5px; + line-height: 1.55; + color: var(--text-2); + border-top: 1px solid var(--border-soft); +} + +.search-hit__snippets li:first-child .snippet { + border-top: none; +} + +.snippet:hover { + background: var(--surface-hover); + color: var(--text); +} + +.snippet__role { + flex: none; + min-width: 46px; + font-size: 10.5px; + color: var(--text-3); +} + +.snippet__text { + flex: 1; + min-width: 0; +} + +.snippet mark { + background: var(--accent-soft); + color: var(--accent); + border-radius: 3px; + padding: 0 2px; +} + +.search-hit__more, +.search-view__truncated { + padding: 9px 14px; + font-size: 11px; + color: var(--text-3); +} + +/* 검색 결과에서 이동해 온 메시지를 잠깐 강조한다 */ +.is-search-hit { + border-radius: 10px; + animation: search-hit-flash 2.4s ease-out; +} + +@keyframes search-hit-flash { + 0%, + 55% { + box-shadow: 0 0 0 3px var(--accent-soft); + } + 100% { + box-shadow: 0 0 0 3px transparent; + } +} From 74a7b4ab914ca4cb3a596ac942754bba6fed955c Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 14:16:05 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/lib/searchIndex.ts | 28 ++-- src/renderer/src/App.tsx | 142 ++++++++++++++++-- .../src/components/ConversationView.tsx | 26 +++- src/renderer/src/components/MessageItems.tsx | 24 ++- src/renderer/src/components/SearchView.tsx | 4 +- src/renderer/src/components/Sidebar.tsx | 39 ++++- src/renderer/src/styles/app.css | 17 ++- src/shared/types.ts | 2 + 8 files changed, 235 insertions(+), 47 deletions(-) diff --git a/src/main/lib/searchIndex.ts b/src/main/lib/searchIndex.ts index 992b81d..512c2e1 100644 --- a/src/main/lib/searchIndex.ts +++ b/src/main/lib/searchIndex.ts @@ -29,7 +29,8 @@ interface StoredDocument { const documents = new Map() let ready = false -let progress: SearchProgress = { done: 0, total: 0, ready: false } +let revision = 0 +let progress: SearchProgress = { done: 0, total: 0, ready: false, revision: 0 } let running: Promise | null = null let lastReconcileAt = 0 let progressWindow: BrowserWindow | null = null @@ -38,7 +39,8 @@ function indexPath(): string { return join(app.getPath('userData'), 'search-index.jsonl') } -function emitProgress(): void { +function emitProgress(done: number, total: number): void { + progress = { done, total, ready, revision } if (progressWindow && !progressWindow.isDestroyed()) { progressWindow.webContents.send('search:progress', progress) } @@ -129,11 +131,11 @@ async function buildDocument(meta: SessionMeta): Promise async function reconcile(): Promise { const projectIds = await listProjectIds() - progress = { done: 0, total: projectIds.length, ready } - emitProgress() + emitProgress(0, projectIds.length) const seen = new Set() let changed = false + let done = 0 for (const projectId of projectIds) { const metas = await listSessions(projectId).catch(() => [] as SessionMeta[]) @@ -153,8 +155,8 @@ async function reconcile(): Promise { else documents.delete(meta.filePath) changed = true } - progress = { done: progress.done + 1, total: projectIds.length, ready } - emitProgress() + done += 1 + emitProgress(done, projectIds.length) // 인덱싱이 렌더러의 IPC를 굶기지 않도록 프로젝트 사이에서 이벤트 루프를 넘긴다 await new Promise((resolve) => setImmediate(resolve)) } @@ -167,8 +169,8 @@ async function reconcile(): Promise { ready = true lastReconcileAt = Date.now() - progress = { ...progress, ready: true } - emitProgress() + if (changed) revision += 1 + emitProgress(done, projectIds.length) if (changed) { await persist().catch((error) => console.error('[search] 인덱스 저장 실패', error)) } @@ -186,8 +188,7 @@ function ensureIndex(): Promise { console.error('[search] 인덱싱 실패', error) ready = true lastReconcileAt = Date.now() - progress = { ...progress, ready: true } - emitProgress() + emitProgress(progress.done, progress.total) } })().finally(() => { running = null @@ -205,11 +206,10 @@ export async function searchSessions(query: string): Promise { const trimmed = query.trim() if (!trimmed) return { query: trimmed, hits: [], truncated: false, indexing: !ready } - if (!ready) { - // 구축 중이면 기다리지 않고 지금까지 만들어진 만큼으로 답한다 + // 정합을 기다리지 않는다 — 지금 인덱스로 즉시 답하고, 갱신되면 revision이 올라 + // 렌더러가 같은 질의를 다시 던진다. 검색이 디스크 스캔에 묶이면 안 된다 + if (!ready || Date.now() - lastReconcileAt > RECONCILE_INTERVAL_MS) { void ensureIndex() - } else if (Date.now() - lastReconcileAt > RECONCILE_INTERVAL_MS) { - await ensureIndex() } const lowered = trimmed.toLowerCase() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0ac6cfa..34bdb84 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,6 +11,9 @@ import type { AppSettings, Conversation, ProjectInfo, + SearchHit, + SearchProgress, + SearchResults, SessionMeta, UpdateInfo } from '../../shared/types' @@ -24,6 +27,7 @@ import { DEFAULT_SETTINGS, PrefsContext, type Prefs } from './prefs' import { buildGroups, SYNTHETIC_PREFIX } from './lib/groups' import { Sidebar } from './components/Sidebar' import { ConversationView } from './components/ConversationView' +import { SearchView } from './components/SearchView' import { ConfirmDialog } from './components/ConfirmDialog' import { SettingsDialog } from './components/SettingsDialog' import { SidebarExpand } from './components/SidebarExpand' @@ -42,6 +46,13 @@ export default function App(): ReactElement { const [showSettings, setShowSettings] = useState(false) const [update, setUpdate] = useState(null) const [toast, setToast] = useState(null) + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [searchProgress, setSearchProgress] = useState(null) + const [highlightRef, setHighlightRef] = useState(null) + const searchViewRef = useRef(null) + const searchRequestRef = useRef(0) const searchRef = useRef(null) const loadedProjects = useRef>(new Set()) const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -143,14 +154,51 @@ export default function App(): ReactElement { // 애플리케이션 메뉴(wake > 설정…)에서 설정 열기 useEffect(() => window.api.onOpenSettings(() => setShowSettings(true)), []) + useEffect(() => window.api.onSearchProgress(setSearchProgress), []) + + // 내용 검색은 200ms 디바운스하고, 인덱스가 갱신되면 같은 질의를 자동으로 다시 돌린다 + useEffect(() => { + if (!searchOpen) return undefined + const trimmed = searchQuery.trim() + if (!trimmed) return undefined + const requestId = ++searchRequestRef.current + const timer = window.setTimeout(() => { + window.api.searchSessions(trimmed).then((results) => { + // 늦게 도착한 이전 질의의 응답은 버린다 + if (requestId === searchRequestRef.current) setSearchResults(results) + }) + }, 200) + return () => window.clearTimeout(timer) + }, [searchOpen, searchQuery, searchProgress?.ready, searchProgress?.revision]) + + // 강조는 대화가 실제로 그려진 뒤부터 재는데, 로딩이 길어도 표시를 놓치지 않기 위해서다 + useEffect(() => { + if (!highlightRef || !conversation) return undefined + const timer = window.setTimeout(() => setHighlightRef(null), 3000) + return () => window.clearTimeout(timer) + }, [highlightRef, conversation]) + // 검색 시 아직 안 읽은 프로젝트의 세션 메타를 모두 로드한다 useEffect(() => { if (!query.trim()) return for (const project of projects) loadSessions(project.id) }, [query, projects, loadSessions]) + const openContentSearch = useCallback(() => { + setSearchOpen(true) + // 사이드바에 입력해 둔 질의가 있으면 그대로 이어받는다 + setSearchQuery((previous) => previous || query) + window.setTimeout(() => searchViewRef.current?.select(), 0) + }, [query]) + useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { + // Shift 조합을 먼저 걸러야 ⌘F 분기가 ⌘⇧F를 가로채지 않는다 + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'f') { + event.preventDefault() + openContentSearch() + return + } if ((event.metaKey || event.ctrlKey) && event.key === 'f') { event.preventDefault() searchRef.current?.focus() @@ -162,11 +210,12 @@ export default function App(): ReactElement { if (event.key === 'Escape') { setShowSettings(false) setDeleteTarget(null) + setSearchOpen(false) } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, []) + }, [openContentSearch]) const toggleProject = useCallback( (projectId: string) => { @@ -183,17 +232,21 @@ export default function App(): ReactElement { [loadSessions] ) - const selectSession = useCallback(async (session: SessionMeta) => { - setSelected(session) - setConversation(null) - setLoadingConversation(true) - try { - const loaded = await window.api.loadConversation(session.filePath) - setConversation(loaded) - } finally { - setLoadingConversation(false) - } - }, []) + const selectSession = useCallback( + async (session: SessionMeta, highlight: string | null = null) => { + setSelected(session) + setConversation(null) + setHighlightRef(highlight) + setLoadingConversation(true) + try { + const loaded = await window.api.loadConversation(session.filePath) + setConversation(loaded) + } finally { + setLoadingConversation(false) + } + }, + [] + ) const openSessionMenu = useCallback( async (session: SessionMeta) => { @@ -212,6 +265,55 @@ export default function App(): ReactElement { [projects, selected] ) + const groups = useMemo(() => buildGroups(projects), [projects]) + + const projectLabel = useCallback( + (projectId: string) => { + const project = projects.find((candidate) => candidate.id === projectId) + if (!project) return projectId + const group = groups.find((candidate) => + candidate.worktrees.some((worktree) => worktree.id === projectId) + ) + // 워크트리 세션은 어느 저장소의 어느 워크트리인지 같이 보여야 구분이 된다 + if (group) return `${group.root.name} ⎇ ${project.worktree?.name ?? project.name}` + return project.name + }, + [groups, projects] + ) + + const openHit = useCallback( + async (hit: SearchHit, ref: string) => { + // 사이드바 목록은 프로젝트당 한 번만 읽어 두므로, 그 뒤 생긴 세션은 검색에만 잡힌다. + // 캐시에서 못 찾으면 한 번 더 읽어 보고 그래도 없을 때만 없는 것으로 판단한다 + let session = sessions[hit.projectId]?.find((meta) => meta.id === hit.sessionId) + if (!session) { + const metas = await window.api.listSessions(hit.projectId) + loadedProjects.current.add(hit.projectId) + setSessions((previous) => ({ ...previous, [hit.projectId]: metas })) + session = metas.find((meta) => meta.id === hit.sessionId) + } + if (!session) { + showToast(t('search.missing')) + return + } + // 워크트리 세션은 그룹 루트까지 펼쳐야 사이드바에서 선택 상태가 보인다 + const group = groups.find( + (candidate) => + candidate.root.id === hit.projectId || + candidate.worktrees.some((worktree) => worktree.id === hit.projectId) + ) + setExpanded((previous) => { + const next = new Set(previous) + if (group) next.add(group.root.id) + next.add(hit.projectId) + return next + }) + setSearchOpen(false) + await selectSession(session, ref || null) + }, + [groups, selectSession, sessions, showToast, t] + ) + const runAction = useCallback( async (kind: 'resume' | 'fork') => { if (!selected) return @@ -271,14 +373,28 @@ export default function App(): ReactElement { onSelectSession={selectSession} onSessionMenu={openSessionMenu} onCollapseSidebar={toggleSidebar} + onOpenContentSearch={openContentSearch} onResizeStart={startSidebarResize} /> - {selected ? ( + {searchOpen ? ( + setSearchOpen(false)} + onExpandSidebar={sidebarCollapsed ? toggleSidebar : null} + /> + ) : selected ? ( runAction('resume')} onFork={() => runAction('fork')} onDelete={() => setDeleteTarget(selected)} diff --git a/src/renderer/src/components/ConversationView.tsx b/src/renderer/src/components/ConversationView.tsx index 025d76d..1d35584 100644 --- a/src/renderer/src/components/ConversationView.tsx +++ b/src/renderer/src/components/ConversationView.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react' +import { useEffect, useRef, type ReactElement } from 'react' import type { Conversation, ProjectInfo, SessionMeta, UserMetaInfo } from '../../../shared/types' import { formatBytes, formatFullDate, shortenPath } from '../lib/format' import { usePrefs } from '../prefs' @@ -13,6 +13,8 @@ interface Props { project: ProjectInfo | null conversation: Conversation | null loading: boolean + /** 검색 결과에서 열었을 때 이동·강조할 아이템 uuid */ + highlightRef: string | null onResume: () => void onFork: () => void onDelete: () => void @@ -25,6 +27,7 @@ export function ConversationView({ project, conversation, loading, + highlightRef, onResume, onFork, onDelete, @@ -32,6 +35,7 @@ export function ConversationView({ onExpandSidebar }: Props): ReactElement { const { t, settings } = usePrefs() + const scrollRef = useRef(null) const cwd = session.cwd ?? project?.realPath ?? null const items = conversation ? conversation.items.filter( @@ -42,6 +46,16 @@ export function ConversationView({ ALWAYS_VISIBLE_META.has(item.meta.kind) ) : [] + + // 검색 결과에서 넘어온 아이템으로 스크롤한다 (강조 해제 시점은 App이 관리한다) + useEffect(() => { + if (!highlightRef || !conversation) return + // start 정렬 — 어시스턴트 턴은 화면보다 길 수 있어 center로 맞추면 강조 테두리가 화면 밖으로 나간다 + scrollRef.current + ?.querySelector(`[data-uuid="${CSS.escape(highlightRef)}"]`) + ?.scrollIntoView({ block: 'start' }) + }, [highlightRef, conversation]) + return (
@@ -74,7 +88,7 @@ export function ConversationView({
-
+
{loading &&

{t('conversation.loading')}

} {!loading && conversation && items.length === 0 && (

{t('conversation.empty')}

@@ -83,9 +97,13 @@ export function ConversationView({
{items.map((item) => item.kind === 'user' ? ( - + ) : ( - + ) )}
diff --git a/src/renderer/src/components/MessageItems.tsx b/src/renderer/src/components/MessageItems.tsx index 7268895..6ee2a2f 100644 --- a/src/renderer/src/components/MessageItems.tsx +++ b/src/renderer/src/components/MessageItems.tsx @@ -100,7 +100,13 @@ function CompactDivider({ item }: { item: UserItem }): ReactElement { ) } -export function UserMessage({ item }: { item: UserItem }): ReactElement { +export function UserMessage({ + item, + highlighted = false +}: { + item: UserItem + highlighted?: boolean +}): ReactElement { const { t } = usePrefs() if (item.meta) { if (item.meta.kind === 'bashRun') return @@ -109,8 +115,9 @@ export function UserMessage({ item }: { item: UserItem }): ReactElement { return } const clock = formatClock(item.timestamp) + // data-uuid는 검색 결과에서 이 메시지로 스크롤할 때 쓴다 (메타 분기는 검색 대상이 아니라 붙이지 않는다) return ( -
+
{clock && }
{item.text}
@@ -146,10 +153,19 @@ function ThinkingBlock({ text }: { text: string }): ReactElement { ) } -export function AssistantTurn({ item }: { item: AssistantItem }): ReactElement { +export function AssistantTurn({ + item, + highlighted = false +}: { + item: AssistantItem + highlighted?: boolean +}): ReactElement { const clock = formatClock(item.timestamp) return ( -
+
{clock && } {item.blocks.map((block, index) => { if (block.type === 'text') return diff --git a/src/renderer/src/components/SearchView.tsx b/src/renderer/src/components/SearchView.tsx index 7f7b736..0825747 100644 --- a/src/renderer/src/components/SearchView.tsx +++ b/src/renderer/src/components/SearchView.tsx @@ -30,7 +30,9 @@ export function SearchView({ }: Props): ReactElement { const { t } = usePrefs() const trimmed = query.trim() - const indexing = progress ? !progress.ready : false + // 응답에 실려 온 상태가 가장 정확하다. 아직 응답도 진행률도 못 받았다면 인덱싱 중으로 본다 + // (인덱싱은 창이 뜨고 잠시 뒤 시작하므로, 그 사이에 "결과 없음"을 보여주면 안 된다) + const indexing = results ? results.indexing : !progress?.ready let status = '' if (indexing) { diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 9949a7f..ea67de0 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -21,6 +21,7 @@ interface Props { onSelectSession: (session: SessionMeta) => void onSessionMenu: (session: SessionMeta) => void onCollapseSidebar: () => void + onOpenContentSearch: () => void onResizeStart: (event: ReactMouseEvent) => void } @@ -98,6 +99,7 @@ export function Sidebar({ onSelectSession, onSessionMenu, onCollapseSidebar, + onOpenContentSearch, onResizeStart }: Props): ReactElement { const { t } = usePrefs() @@ -130,14 +132,35 @@ export function Sidebar({
- onQueryChange(event.target.value)} - spellCheck={false} - /> +
+ onQueryChange(event.target.value)} + spellCheck={false} + /> + +
)} diff --git a/src/renderer/src/components/SearchPalette.tsx b/src/renderer/src/components/SearchPalette.tsx index 803a9d0..413a570 100644 --- a/src/renderer/src/components/SearchPalette.tsx +++ b/src/renderer/src/components/SearchPalette.tsx @@ -54,25 +54,37 @@ export function SearchPalette({ listRef.current?.querySelector('.snippet.is-active')?.scrollIntoView({ block: 'nearest' }) }, [activeIndex]) + // 닫을 때 팔레트를 연 컨트롤로 포커스를 돌려준다. + // 첫 렌더에서 잡아야 한다 — 이펙트 시점에는 입력창의 autoFocus가 이미 가져간 뒤다 + const [opener] = useState(() => document.activeElement) + useEffect( + () => () => { + if (opener instanceof HTMLElement && opener.isConnected) opener.focus() + }, + [opener] + ) + const trimmed = query.trim() // 응답도 진행률도 못 받았으면 인덱싱 중으로 본다 — 그 사이에 "결과 없음"을 보여주면 안 된다 const indexing = results ? results.indexing : !progress?.ready let status: string - if (failed) { - status = t('search.failed') - } else if (indexing) { + if (indexing) { status = progress && progress.total > 0 ? t('search.indexing', { done: progress.done, total: progress.total }) : t('search.indexingPrep') } else if (!trimmed) { + // 질의를 지웠으면 직전 실패보다 안내가 먼저다 status = t('search.hint') + } else if (failed) { + status = t('search.failed') } else if (!results) { // 디바운스·왕복 중이다. 아직 결과가 없다고 단정하지 않는다 status = t('search.searching') - } else if (results.degraded && results.hits.length === 0) { - status = t('search.failed') + } else if (results.degraded) { + // 인덱스가 불완전하면 찾은 게 있어도 "이게 전부"라고 말하지 않는다 + status = results.hits.length === 0 ? t('search.failed') : t('search.partial') } else if (results.hits.length === 0) { status = t('search.empty', { query: trimmed }) } else { @@ -100,8 +112,14 @@ export function SearchPalette({
event.stopPropagation()} + // Tab으로 뒤 화면의 컨트롤로 빠져나가면 ↑↓·↵가 더는 듣지 않는다. + // 팔레트는 입력창에서만 조작하므로 Tab 자체를 막아 포커스를 붙잡아 둔다 + onKeyDown={(event) => { + if (event.key === 'Tab') event.preventDefault() + }} >
{t('search.open')} - ⌘K + {shortcut('K')}
diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 9aec1f2..ec6a4f3 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -3,7 +3,7 @@ import type { AppSettings } from '../../shared/types' export type Lang = 'ko' | 'en' const ko = { - 'sidebar.search': '세션·폴더 검색 (⌘F)', + 'sidebar.search': '세션·폴더 검색 ({find})', 'sidebar.loading': '불러오는 중…', 'sidebar.empty': '~/.claude/projects 에서\n세션을 찾지 못했습니다.', 'sidebar.collapse': '사이드바 접기', @@ -11,6 +11,7 @@ const ko = { 'search.open': '대화 내용 검색', 'search.placeholder': '기억나는 단어로 대화 내용 검색', 'search.hint': '모든 프로젝트의 대화 본문에서 찾습니다. ↑↓ 이동 · ↵ 열기', + 'search.partial': '인덱싱에 실패해 결과가 불완전할 수 있습니다', 'search.indexing': '인덱싱 중… {done}/{total}', 'search.indexingPrep': '인덱싱 준비 중…', 'search.searching': '검색 중…', @@ -28,7 +29,7 @@ const ko = { 'menu.delete': '삭제…', 'session.messages': '메시지 {n}', 'empty.title': '왼쪽에서 세션을 선택하세요', - 'empty.hint': '⌘F 세션 목록 좁히기 · ⌘K 대화 내용 검색', + 'empty.hint': '{find} 세션 목록 좁히기 · {search} 대화 내용 검색', 'action.resume': '터미널에서 이어가기', 'action.resume.hint': '터미널에서 이 세션을 이어서 연다', 'action.fork': 'Fork로 열기', @@ -106,7 +107,7 @@ const ko = { } const en: Record = { - 'sidebar.search': 'Filter sessions & folders (⌘F)', + 'sidebar.search': 'Filter sessions & folders ({find})', 'sidebar.loading': 'Loading…', 'sidebar.empty': 'No sessions found in\n~/.claude/projects.', 'sidebar.collapse': 'Collapse sidebar', @@ -114,6 +115,7 @@ const en: Record = { 'search.open': 'Search conversations', 'search.placeholder': 'Search across all conversations', 'search.hint': 'Searches message contents across every project. ↑↓ to move · ↵ to open', + 'search.partial': 'Indexing failed — these results may be incomplete', 'search.indexing': 'Indexing… {done}/{total}', 'search.indexingPrep': 'Preparing index…', 'search.searching': 'Searching…', @@ -131,7 +133,7 @@ const en: Record = { 'menu.delete': 'Delete…', 'session.messages': '{n} messages', 'empty.title': 'Select a session from the sidebar', - 'empty.hint': '⌘F to filter sessions · ⌘K to search conversations', + 'empty.hint': '{find} to filter sessions · {search} to search conversations', 'action.resume': 'Resume in terminal', 'action.resume.hint': 'Reopen this session in a terminal', 'action.fork': 'Open as fork', diff --git a/src/renderer/src/lib/platform.ts b/src/renderer/src/lib/platform.ts new file mode 100644 index 0000000..3293a70 --- /dev/null +++ b/src/renderer/src/lib/platform.ts @@ -0,0 +1,9 @@ +/** + * 단축키는 metaKey와 ctrlKey를 함께 받으므로, 표기는 플랫폼에 맞춰 하나만 보여준다. + * 둘 다 적으면(⌘/Ctrl+K) 좁은 칩 안에서 읽기 어렵다. + */ +const IS_MAC = navigator.userAgent.includes('Macintosh') + +export function shortcut(key: string): string { + return IS_MAC ? `⌘${key}` : `Ctrl+${key}` +} From d2c70e4a969ccb1ffecd912587aecb4c8704a45b Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 19:17:13 +0900 Subject: [PATCH 14/15] =?UTF-8?q?docs:=200.7.0=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/src/content/changelog/en/0.7.0.md | 15 +++++++++++++++ website/src/content/changelog/ko/0.7.0.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 website/src/content/changelog/en/0.7.0.md create mode 100644 website/src/content/changelog/ko/0.7.0.md diff --git a/website/src/content/changelog/en/0.7.0.md b/website/src/content/changelog/en/0.7.0.md new file mode 100644 index 0000000..2070026 --- /dev/null +++ b/website/src/content/changelog/en/0.7.0.md @@ -0,0 +1,15 @@ +--- +version: '0.7.0' +date: '2026-08-08' +highlight: One half-remembered word searches every conversation and takes you to the message. +--- + +### New + +- **Search what was actually said (`⌘K`)** — the search field at the top of the window searches message contents across every project, so you can find the thing that never made it into a title. Move with `↑↓`, open with `↵`, and Wake scrolls to that exact message and highlights it briefly. The index holds only what you and Claude said — never tool input or output — and never leaves your machine +- **Sidebar search matches folder names too** — `⌘F` now looks at project and worktree names alongside session titles and first prompts. Match a folder and every session inside it stays in view +- **Collapse and expand the sidebar from anywhere** — the toggle moved out of the sidebar and into the window's top bar, so it stays reachable even while the sidebar is hidden + +### Fixed + +- **`⌘F` no longer breaks with Caps Lock on** — the key arrived in a different form with Caps Lock enabled, and session search silently did nothing diff --git a/website/src/content/changelog/ko/0.7.0.md b/website/src/content/changelog/ko/0.7.0.md new file mode 100644 index 0000000..8a039a3 --- /dev/null +++ b/website/src/content/changelog/ko/0.7.0.md @@ -0,0 +1,15 @@ +--- +version: '0.7.0' +date: '2026-08-08' +highlight: 기억나는 단어 하나로 모든 대화 본문을 뒤져 그 메시지로 바로 갑니다. +--- + +### 새 기능 + +- **대화 내용 검색이 생겼습니다 (`⌘K`)** — 창 상단 검색창에서 모든 프로젝트의 대화 본문을 찾습니다. 제목에는 남지 않은 이야기도 기억나는 단어 하나로 짚어냅니다. `↑↓` 로 훑고 `↵` 로 열면 그 세션의 해당 메시지까지 스크롤되며 잠깐 강조됩니다. 색인은 주고받은 말만 담아 기기 안에 두고, 도구 입출력은 담지 않으며 어디로도 보내지 않습니다 +- **사이드바 검색이 폴더 이름도 찾습니다** — `⌘F` 가 세션 제목과 첫 메시지에 더해 프로젝트·워크트리 이름까지 봅니다. 폴더 이름이 걸리면 그 안의 세션을 통째로 보여줍니다 +- **사이드바를 언제든 접고 펼 수 있습니다** — 접기 버튼이 사이드바 안이 아니라 창 상단 바에 있어, 접은 상태에서도 항상 손에 닿습니다 + +### 버그 수정 + +- **CapsLock이 켜져 있으면 `⌘F` 가 듣지 않던 문제 수정** — 대문자 잠금 상태에서 키 입력이 다르게 전달돼 세션 검색이 아무 반응 없이 넘어가던 것을 고쳤습니다 From d8cfcf13cf1f7a7eb419f63032fec8c39bcb0170 Mon Sep 17 00:00:00 2001 From: JeongUk Park Date: Sat, 8 Aug 2026 19:17:14 +0900 Subject: [PATCH 15/15] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=200.7.0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=83=81=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fbc65d..2bec28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wake", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wake", - "version": "0.6.0", + "version": "0.7.0", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.2", diff --git a/package.json b/package.json index 3f0ae4e..9cd7701 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wake", - "version": "0.6.0", + "version": "0.7.0", "description": "Wake — trace the wake of your Claude Code sessions. A local conversation history viewer.", "main": "./out/main/index.js", "author": "followingseas",