diff --git a/README.md b/README.md index b8189d0..b918720 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ Codex 작업은 설치된 스킬과 플러그인을 사용합니다. 에이전 첨부 파일, API 응답을 캐시하지 않습니다. 공개 호스팅, macOS·iOS 네이티브 실행 파일, 앱 자체의 계정 관리는 이번 구현 범위에 포함하지 않습니다. +공통 화면은 메신저형 에이전트 목록, 모바일 목록·대화 전환, 파일 선택·붙여넣기 큐를 제공합니다. [구현·검증 범위](docs/plans/codex-ui/010_implementation.md)와 [Electron 실행 안내](packages/lina-desktop/README.md)를 참고하세요. 실제 모델·다른 OS 연결 검증은 소스 테스트와 구분합니다. + ## 디렉터리 구성 - `packages/lina-codex`: Codex RPC, 세션 상태 반영, 권한, 작업 상태의 영속 관리. @@ -165,7 +167,10 @@ Codex 작업은 설치된 스킬과 플러그인을 사용합니다. 에이전 - `packages/lina-core`: 대화·요청의 영속 처리 규약, 원문 보관, 컨텍스트 저장. - `packages/lina-history`: 비공개 체크포인트 명세, 데이터 객체, 검증, 복구 준비. - `packages/lina-runtime`: 앱·에이전트 집합 컨트롤러와 Codex 실행 구성. -- `packages/lina-web`: 브라우저 작업 화면과 동일 출처 게이트웨이. +- `packages/lina-client`: 공통 대화 상태·명령·초안·탐색 계약. +- `packages/lina-ui`: 웹과 Electron이 함께 사용하는 화면과 디자인 토큰. +- `packages/lina-web`: 동일 출처 게이트웨이와 웹/PWA 자산. +- `packages/lina-desktop`: 동봉 화면과 제한된 서버 연결을 제공하는 Electron 앱. - `packages/lina-memory`: 내장·Honcho 메모리와 접근 범위를 제한한 OpenViking 파일 클라이언트. - `packages/lina-channels`: 채널 연동. - `data/personas`: 작성된 페르소나와 출처 기록. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 74b33dd..13ef20d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -69,3 +69,34 @@ Apache-2.0 to the extent of the rights held. That statement covers the identifie assets; it does not relicense external software or imply rights in other images. [The publication checklist](docs/PUBLICATION.md) tracks artifact notices, source privacy review, and the separate activation steps. + + +## shadcn/ui component source + +The UI components in `packages/lina-ui/client/components/ui/` adapt the +Base UI `base-nova` registry patterns retrieved on 2026-09-07 from +https://ui.shadcn.com/r/styles/base-nova/. Lina replaces Tailwind classes with +its shared semantic CSS tokens and keeps named exports. Base UI and React +remain pinned package dependencies in `packages/lina-ui/package.json`. + +MIT License + +Copyright (c) 2023 shadcn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bun.lock b/bun.lock index 3b2392e..b0097a6 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,10 @@ "@types/ws": "^8.18.1", }, }, + "packages/lina-client": { + "name": "@lina/client", + "version": "0.1.0", + }, "packages/lina-codex": { "name": "@lina/codex", "version": "0.1.0", @@ -33,6 +37,15 @@ "name": "@lina/core", "version": "0.1.0", }, + "packages/lina-desktop": { + "name": "@lina/desktop", + "version": "0.1.0", + "devDependencies": { + "@electron-forge/cli": "8.0.0-alpha.10", + "@electron-forge/maker-zip": "8.0.0-alpha.10", + "electron": "44.2.0", + }, + }, "packages/lina-history": { "name": "@lina/history", "version": "0.1.0", @@ -60,12 +73,31 @@ "@types/ws": "^8.18.1", }, }, + "packages/lina-ui": { + "name": "@lina/ui", + "version": "0.1.0", + "dependencies": { + "@base-ui/react": "1.8.0", + "react": "19.2.8", + "react-dom": "19.2.8", + }, + "devDependencies": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7", + }, + }, "packages/lina-web": { "name": "@lina/web", "version": "0.1.0", }, }, "packages": { + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@base-ui/react": ["@base-ui/react@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "0.4.0", "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q=="], + + "@base-ui/utils": ["@base-ui/utils@0.4.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="], @@ -96,12 +128,62 @@ "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@electron-forge/cli": ["@electron-forge/cli@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/core": "8.0.0-alpha.10", "@electron-forge/core-utils": "8.0.0-alpha.10", "@electron-forge/shared-types": "8.0.0-alpha.10", "@electron/get": "^5.0.0", "commander": "^11.1.0", "debug": "^4.3.1", "listr2": "^7.0.2", "semver": "^7.2.1" }, "bin": { "electron-forge": "dist/electron-forge.js", "electron-forge-vscode-nix": "script/vscode.sh", "electron-forge-vscode-win": "script/vscode.cmd" } }, "sha512-3fkKH50xTVN1A+UhsX6BzwFfP7JVTadIrA3Cs4jpR7Yl/PChH4w/cyi99LNnZnVAypiywkOkqrQU9t+1SZy1YA=="], + + "@electron-forge/core": ["@electron-forge/core@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/core-utils": "8.0.0-alpha.10", "@electron-forge/maker-base": "8.0.0-alpha.10", "@electron-forge/plugin-base": "8.0.0-alpha.10", "@electron-forge/publisher-base": "8.0.0-alpha.10", "@electron-forge/shared-types": "8.0.0-alpha.10", "@electron-forge/tracer": "8.0.0-alpha.10", "@electron/get": "^5.0.0", "@electron/packager": "^20.0.1", "debug": "^4.3.1", "graceful-fs": "^4.2.11", "jiti": "^2.4.2", "listr2": "^7.0.2" } }, "sha512-sg52Ay0vy9ShC7G4CL9fsfzcUC4yAI9HdP7D18tdmbPwZJ6DLqDLKT/pFw297V7IjX4AYlpsW/71yPEqadDm3w=="], + + "@electron-forge/core-utils": ["@electron-forge/core-utils@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/shared-types": "8.0.0-alpha.10", "@electron/rebuild": "^4.0.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "graceful-fs": "^4.2.11", "semver": "^7.2.1" } }, "sha512-edL4xReqbWStPhdhgSEE55AXXLtJLxMRtHEghulmZlf4UaSfS86zwSBtqDwYcUB1cd9LpcEm3GKKek/awOJB0A=="], + + "@electron-forge/maker-base": ["@electron-forge/maker-base@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/shared-types": "8.0.0-alpha.10", "which": "^6.0.0" } }, "sha512-aZ7YlU785r/1VPy0h1HHy1VEiufqMX0fd4tzHcAWwDfZguajfhnGioPfgCaEVKBWyAgV3v7Pge2FkL7YcRsxsQ=="], + + "@electron-forge/maker-zip": ["@electron-forge/maker-zip@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/core-utils": "8.0.0-alpha.10", "@electron-forge/maker-base": "8.0.0-alpha.10", "@electron-forge/shared-types": "8.0.0-alpha.10", "cross-zip": "^4.0.0" } }, "sha512-I3N9FI8xJW7f+Ld05f2hSSuukfI2Oh9vKN7HWSPmM8+7PqN4Dwigp7DRv/s3HPFwrMdayDJKm/2me5rvXh32DQ=="], + + "@electron-forge/plugin-base": ["@electron-forge/plugin-base@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/shared-types": "8.0.0-alpha.10" } }, "sha512-AoL+VuuFVLgqeRzO0dLvrx4f2t1nMeHQ1YKj/EoqAQ6uU7D4HS2D4FNEXyxTQFVrNj4OqSte7U3sqGenc327XA=="], + + "@electron-forge/publisher-base": ["@electron-forge/publisher-base@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/shared-types": "8.0.0-alpha.10" } }, "sha512-UjGRM13jVr1oq+HLayJAUiQcfxvs8LyTQYm5sazxlfG9LO9UJAI/2jbNic/OXYehr5xGrJSCMXDTm/cCy5LfZQ=="], + + "@electron-forge/shared-types": ["@electron-forge/shared-types@8.0.0-alpha.10", "", { "dependencies": { "@electron-forge/tracer": "8.0.0-alpha.10", "@electron/packager": "^20.0.1", "@electron/rebuild": "^4.0.1", "listr2": "^7.0.2" } }, "sha512-JdwOXHXXjh1L1rgLcQJfyCX8cHgvognmuol/udDUIx9/JzMc+AZhNnsFN8JriRYunYaFrVLTHe0H8f8GQXO/LA=="], + + "@electron-forge/tracer": ["@electron-forge/tracer@8.0.0-alpha.10", "", { "dependencies": { "chrome-trace-event": "^1.0.3" } }, "sha512-aoW9P+KoTtO0KQaISdJXi3sVB5k12P1kA6pK0NsgJTEbsB2i5O6c8zfor/U5eJUeb9GAVscAIHKShYuUycgZqg=="], + + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="], + + "@electron/asar": ["@electron/asar@4.3.0", "", { "dependencies": { "glob": "^13.0.2", "minimatch": "^10.0.1" }, "bin": { "asar": "bin/asar.mjs" } }, "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ=="], + + "@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="], + + "@electron/notarize": ["@electron/notarize@3.1.1", "", { "dependencies": { "debug": "^4.4.0", "promise-retry": "^2.0.1" } }, "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ=="], + + "@electron/osx-sign": ["@electron/osx-sign@2.7.0", "", { "dependencies": { "debug": "^4.3.4", "isbinaryfile": "^4.0.8", "plist": "^3.0.5", "semver": "^7.7.1" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.mjs", "electron-osx-sign": "bin/electron-osx-sign.mjs" } }, "sha512-9DGhNqKMl6ibkhUoXbN7OHX2gZznfY10L3ZwG0u6r667Kfb6kec4JEfFTXftoqzmOfZ+OzwDbr4p/nKBMHnz0g=="], + + "@electron/packager": ["@electron/packager@20.3.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/asar": "^4.0.1", "@electron/get": "^5.0.0", "@electron/notarize": "^3.1.0", "@electron/osx-sign": "^2.2.0", "@electron/universal": "^3.0.1", "@electron/windows-sign": "^2.0.2", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.4.1", "filenamify": "^6.0.0", "galactus": "^2.0.2", "graceful-fs": "^4.2.11", "junk": "^4.0.1", "plist": "^3.1.0", "resedit": "^2.0.3", "semver": "^7.7.2", "yargs-parser": "^22.0.0" }, "bin": { "electron-packager": "bin/electron-packager.mjs" } }, "sha512-3MvgJgy6YJ5ti0oGGBKrWKdYwpTaoRrhranMDgHSQ6i5t56yZV9IRDyoXTuqBp97LiKqnZeAMe2wTcF/9+fP5g=="], + + "@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="], + + "@electron/universal": ["@electron/universal@3.0.6", "", { "dependencies": { "@electron/asar": "^4.0.0", "debug": "^4.3.1", "plist": "^3.1.0" } }, "sha512-MonS1kfkZdSEkLZI0pdR/TCx8ecxwRSFm7sORfwIkDI9UaIbHnk4Mgeqq+Ob9qDQRV8LZ9+hHCmimpA9BRcNxw=="], + + "@electron/windows-sign": ["@electron/windows-sign@2.0.6", "", { "dependencies": { "debug": "^4.3.4", "graceful-fs": "^4.2.11", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.mjs" } }, "sha512-ESWgNkFsXFH06I5EB2uHv3hmZA3yF4j9GTB77W74x3b5KZNItxggNzuADw41HUy6mhnC2K+E2mT0Xgc4YKu3IQ=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@lina/channels": ["@lina/channels@workspace:packages/lina-channels"], + "@lina/client": ["@lina/client@workspace:packages/lina-client"], + "@lina/codex": ["@lina/codex@workspace:packages/lina-codex"], "@lina/core": ["@lina/core@workspace:packages/lina-core"], + "@lina/desktop": ["@lina/desktop@workspace:packages/lina-desktop"], + "@lina/history": ["@lina/history@workspace:packages/lina-history"], "@lina/memory": ["@lina/memory@workspace:packages/lina-memory"], @@ -110,8 +192,12 @@ "@lina/runtime": ["@lina/runtime@workspace:packages/lina-runtime"], + "@lina/ui": ["@lina/ui@workspace:packages/lina-ui"], + "@lina/web": ["@lina/web@workspace:packages/lina-web"], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], @@ -120,28 +206,190 @@ "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], + + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + + "ansi-escapes": ["ansi-escapes@5.0.0", "", { "dependencies": { "type-fest": "^1.0.2" } }, "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@3.1.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^5.0.0" } }, "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cross-zip": ["cross-zip@4.0.1", "", {}, "sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "discord-api-types": ["discord-api-types@0.38.55", "", {}, "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA=="], "discord.js": ["discord.js@14.27.0", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron": ["electron@44.2.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "filename-reserved-regex": ["filename-reserved-regex@3.0.0", "", {}, "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw=="], + + "filenamify": ["filenamify@6.0.0", "", { "dependencies": { "filename-reserved-regex": "^3.0.0" } }, "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ=="], + + "flora-colossus": ["flora-colossus@3.0.2", "", { "dependencies": { "debug": "^4.4.1" } }, "sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg=="], + + "galactus": ["galactus@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "flora-colossus": "^3.0.2" } }, "sha512-HmKyTFGomdAchz4umx8MwBnrnfFmdpwiTyGA4ZOF7rya2Lmgbc9qate4yweInL+0gUBVImhaz12SBGpW3SY4Yg=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + + "isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "junk": ["junk@4.0.1", "", {}, "sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ=="], + + "listr2": ["listr2@7.0.2", "", { "dependencies": { "cli-truncate": "^3.1.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^5.0.1", "rfdc": "^1.3.0", "wrap-ansi": "^8.1.0" } }, "sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "log-update": ["log-update@5.0.1", "", { "dependencies": { "ansi-escapes": "^5.0.0", "cli-cursor": "^4.0.0", "slice-ansi": "^5.0.0", "strip-ansi": "^7.0.1", "wrap-ansi": "^8.0.1" } }, "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-abi": ["node-abi@4.35.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q=="], + + "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "pe-library": ["pe-library@1.0.1", "", {}, "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + + "resedit": ["resedit@2.0.3", "", { "dependencies": { "pe-library": "^1.0.1" } }, "sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA=="], + + "reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], + "typebox": ["typebox@1.3.18", "", {}, "sha512-/wYPoDqxWZSxV/XD8Eskzr3YluXC9CaWJOuUYMkj+lLVLkyeEIQKzHvMuS/IRc3OLTIBC32LAtHgXo/WFEOMHQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -150,12 +398,38 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@electron/get/undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "electron/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/docs/PLANNING.md b/docs/PLANNING.md index a4912cc..d8e66b5 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -6,10 +6,10 @@ | 기획 | 문서 | 현재 상태 | | --- | --- | --- | -| Codex 디자인 언어, Electron과 웹 공통 화면 | [UI 개편](plans/codex-ui/001_ui_plan.md), [Electron](plans/codex-ui/004_electron_stack.md), [디자인 기준](../packages/lina-web/DESIGN.md) | 방향 확정, UI 전면 개편과 Electron 패키징은 후속 구현 | +| Codex 디자인 언어, Electron과 웹 공통 화면 | [UI 개편](plans/codex-ui/001_ui_plan.md), [Electron](plans/codex-ui/004_electron_stack.md), [디자인 기준](../packages/lina-web/DESIGN.md) | 공통 화면·토큰·Electron 동봉 빌드 구현. [검증 범위](plans/codex-ui/010_implementation.md) | | 메신저형 에이전트 목록과 모바일 탐색 | [에이전트 목록](plans/codex-ui/006_agent_list_mobile.md), [작업 목록 배치](plans/codex-ui/007_sidebar_comparison.md) | 기본 한 목록 열, 넓은 창의 선택적 작업 열, 모바일 전체 화면 전환 | | 연속 메시지와 중간 사용자 입력 | [대화 전달](plans/codex-ui/002_conversation_delivery.md) | 공개된 발화와 미공개 후보를 나누는 엔진·저장 계약 설계 | -| 이미지·파일 붙여넣기 | [첨부 계획](plans/codex-ui/003_clipboard_attachment.md) | 공통 첨부 큐와 플랫폼별 검증이 필요한 계획 | +| 이미지·파일 붙여넣기 | [첨부 계획](plans/codex-ui/003_clipboard_attachment.md) | 파일 선택·붙여넣기 공통 큐 구현, Chromium 검증. 다른 OS·브라우저는 별도 검증 | | 전용 컴퓨터의 공통 계약 | [컴퓨터·실행·입력 소유권](plans/platform/001_runtime_contracts.md) | API·저장 계약 제안, 독립 GUI 입력은 아직 미검증 | | 프로바이더 선택과 로컬 모델 큐레이션 | [모델 온보딩](plans/platform/005_model_onboarding.md) | 제시 순서·큐레이션 요구 보존, 현재 연결은 Codex + OpenCodex | | 설치 구성과 전체 상태 버전관리 | [리팩터링 요구](plans/platform/008_refactor_preparation.md) | 홈·패키지 분리·로컬 체크포인트 구현, 자동 이력·선택 복원·외부 내보내기는 후속 | diff --git a/docs/plans/codex-ui/001_ui_plan.md b/docs/plans/codex-ui/001_ui_plan.md index 041756a..c79ba5e 100644 --- a/docs/plans/codex-ui/001_ui_plan.md +++ b/docs/plans/codex-ui/001_ui_plan.md @@ -1,5 +1,7 @@ # Lina UI 개편 계획 +> 구현 전 설계와 당시 코드 조사 기록입니다. 현재 구현·검증·남은 연결은 [구현 기록](010_implementation.md)을 따릅니다. + 상태: 제품 설계. Electron·메신저형 목록·연속 발화는 후속 구현이며 현재 기능은 [기획 목록](../../PLANNING.md)을 따른다. **Codex의 디자인 언어를 전면 채택하고, 에이전트와의 지속 대화를 탐색의 중심에 둡니다.** 화면을 바꿔도 대화·초안·작업이 이어져야 합니다. 근거와 코드 확보 범위는 [조사 문서](000_source_research.md)에 정리했습니다. 데스크톱은 Electron, 웹/PWA는 같은 렌더러를 사용합니다. [스택과 전환 계획](004_electron_stack.md), [디자인 적용 기준](005_codex_design_language.md)이 각각 구현 경계와 시각 검수의 기준입니다. diff --git a/docs/plans/codex-ui/003_clipboard_attachment.md b/docs/plans/codex-ui/003_clipboard_attachment.md index 8e9fdb5..3528fe0 100644 --- a/docs/plans/codex-ui/003_clipboard_attachment.md +++ b/docs/plans/codex-ui/003_clipboard_attachment.md @@ -1,5 +1,7 @@ # 이미지·파일 붙여넣기 첨부 +> 구현 전 설계와 당시 코드 조사 기록입니다. 현재 구현·검증·남은 연결은 [구현 기록](010_implementation.md)을 따릅니다. + 2026-09-07 사용자 피드백 5 반영. 기획과 현재 코드 검사만 수행했습니다. 실제 OS 클립보드를 사용한 브라우저·모바일 검증은 아직 수행하지 않았습니다. ## 1. 현재 지원 범위 diff --git a/docs/plans/codex-ui/004_electron_stack.md b/docs/plans/codex-ui/004_electron_stack.md index 03af2d9..5ce9c38 100644 --- a/docs/plans/codex-ui/004_electron_stack.md +++ b/docs/plans/codex-ui/004_electron_stack.md @@ -1,5 +1,7 @@ # Electron과 웹 공통 스택 +> 구현 전 설계와 당시 코드 조사 기록입니다. 현재 구현·검증·남은 연결은 [구현 기록](010_implementation.md)을 따릅니다. + 2026-09-07 사용자 선택 반영. 데스크톱은 Electron으로 정하고, 웹은 동일한 화면 코드를 브라우저에서 실행합니다. 이번 변경은 스택·전환 기획이며 패키지 설치나 앱 구현을 수행한 것은 아닙니다. ## 1. 스택 결정 diff --git a/docs/plans/codex-ui/006_agent_list_mobile.md b/docs/plans/codex-ui/006_agent_list_mobile.md index ace92a4..d75bf21 100644 --- a/docs/plans/codex-ui/006_agent_list_mobile.md +++ b/docs/plans/codex-ui/006_agent_list_mobile.md @@ -1,5 +1,7 @@ # 에이전트 목록과 모바일 탐색 +> 구현 전 설계와 당시 코드 조사 기록입니다. 현재 구현·검증·남은 연결은 [구현 기록](010_implementation.md)을 따릅니다. + 2026-09-07 추가 피드백 반영. 사용자는 Telegram·Grok Bot·카카오톡처럼 익숙한 에이전트 선택 경험과 모바일 전환을 요청했습니다. Codex의 색·타이포·입력창·메뉴 규칙 안에 메신저형 목록과 탐색을 적용합니다. 이 문서는 기획이며 앱 구현 결과가 아닙니다. ## 1. 변경하는 결정 diff --git a/docs/plans/codex-ui/007_sidebar_comparison.md b/docs/plans/codex-ui/007_sidebar_comparison.md index 52fa2a5..b3c6304 100644 --- a/docs/plans/codex-ui/007_sidebar_comparison.md +++ b/docs/plans/codex-ui/007_sidebar_comparison.md @@ -1,5 +1,7 @@ # 에이전트와 Codex 작업 목록 배치 비교 +> 구현 전 설계와 당시 코드 조사 기록입니다. 현재 구현·검증·남은 연결은 [구현 기록](010_implementation.md)을 따릅니다. + 2026-09-07. 사용자는 사이드바를 위아래로 나눌지, 작업용 열을 추가할지 비교해 달라고 요청했습니다. 아래 권장안은 메신저형 에이전트 목록과 모바일 전환을 함께 고려한 설계 판단입니다. 아직 구현·사용성 시험 결과는 없습니다. ## 1. 권장안 diff --git a/docs/plans/codex-ui/010_implementation.md b/docs/plans/codex-ui/010_implementation.md new file mode 100644 index 0000000..bddd8ea --- /dev/null +++ b/docs/plans/codex-ui/010_implementation.md @@ -0,0 +1,134 @@ +# UI 및 데스크톱 구현·검증 + +웹과 Electron이 같은 화면을 사용하도록 분리하고, 에이전트 중심 탐색과 +Codex 계열 디자인을 적용했다. 기존 대화·설정·온보딩을 유지하면서 모바일 +전환, 파일 붙여넣기, 작업 보기와 데스크톱 연결을 구현했다. + +## 구현 범위 + +| 소유 패키지 | 책임 | +| --- | --- | +| `lina-client` | 대화 상태·연결·프로토콜, 에이전트 요약, 초안·읽기 위치, 첨부 큐, 탐색 | +| `lina-ui` | 공통 renderer, 디자인 토큰, React/Base UI 컴포넌트와 기존 컨트롤러 연결 | +| `lina-web` | 동일 출처 게이트웨이, 공통 화면의 production 빌드와 웹/PWA 자산 | +| `lina-desktop` | 동봉 renderer, 제한된 loopback broker, 프로필·세션 격리와 OS 연결 경계 | + +에이전트 목록은 이름·최근 대화·시간·읽지 않음·확인 요청·진행 상태를 표시한다. +목록 필터와 순서를 유지하며 대상을 바꿔도 초안·첨부·읽기 위치가 섞이지 않는다. +요약은 저장된 공개 대화에서 읽고, 목록 조회만으로 실행 엔진을 열지 않는다. + +화면은 중립 표면·공통 글자·간격·버튼·아이콘 크기를 쓴다. React 19.2.8, +React DOM 19.2.8, Base UI 1.8.0을 추가했다. shadcn base-nova의 Button, +Avatar, DropdownMenu, Tabs, Combobox 구성을 Lina 토큰/CSS로 조정했다. +[소스 출처](../../../packages/lina-ui/SOURCES.md)와 +[포함 라이선스](../../../packages/lina-ui/THIRD_PARTY_LICENSES.txt)를 보존한다. +Tailwind나 별도 웹 서버는 추가하지 않았다. + +React는 각 view host 안의 컨트롤을 소유한다. 기존 대화·입력기·첨부 큐는 +기존 컨트롤러가 계속 소유한다. 목록 알림, 메뉴 동작, 설정 탭, 모델 검색은 +명시적 메서드/콜백으로 연결한다. 모델 설정 컨트롤러에는 콤보박스 팩토리를 +주입해 서버 테스트에 브라우저 타입이 섞이지 않게 했다. + +모바일은 에이전트 목록 → 대화 → 뒤로가기 흐름을 제공한다. 상단 사이드바 +아이콘은 접기만 담당한다. 작업 탭 안의 이름 있는 `나란히 보기` 버튼이 별도 +작업 열을 열고, 같은 버튼이 `나란히 보기 종료`로 바뀐다. 실제 남는 폭이 +부족하면 이 선택지를 숨긴다. 목록·필터·초안과 키보드 포커스를 보존한다. + +파일 선택과 붙여넣기는 같은 큐를 쓴다. PNG의 clipboard items/files가 다른 +메타데이터를 내놓아도 중복 업로드하지 않는다. 실패 시 명시적으로 재시도하며, +준비되지 않은 첨부를 빼고 전송하거나 붙여넣기만으로 자동 전송하지 않는다. + +## 데스크톱 + +Electron 44.2.0, Forge CLI/ZIP maker 8.0.0-alpha.10을 추가했다. 선택한 Forge +버전은 설치된 중첩 의존성까지 감사했다. 앱은 실행 엔진을 시작하지 않고 +독립적으로 실행 중인 Lina 웹 게이트웨이에 연결한다. + +같은 production renderer를 ASAR에 동봉한다. main의 loopback broker는 임시 +HttpOnly 인증, 정확한 Host/Origin, 메서드·경로 허용 목록과 프로필의 서버 +바인딩을 확인한다. renderer에는 Node 권한을 주지 않고 preload도 제한한다. +프로필과 세션 경로를 별도로 지정할 수 있다. 인증 실패 중 TCP reset과 창 닫기 +취소의 연결 유지도 회귀 검사했다. [실행·패키징 안내](../../../packages/lina-desktop/README.md). + +## 화면 증거 + +아래 PNG는 합성 데이터로 실제 웹 UI를 실행해 캡처한 결과다. 개인 대화, +실모델 출력이나 Electron GUI 실행 증거가 아니다. 화면 안의 로고·아이콘은 +저장소의 기존 UI 자산이며, 모의 대화·프로필은 QA fixture가 제공한다. + +![데스크톱](010_ui_desktop.png) + +![모바일 에이전트 목록](010_ui_mobile.png) + +![역할별 모델 검색](010_ui_models.png) + +![작업 나란히 보기](010_ui_task_layout.png) + +## 검증 + +`dev`의 이미지 엔진 변경까지 통합한 PR 후보에서 `bun test` 1,495개가 +통과했다. `bun run typecheck`, `bun run lint`, `bun run ci:validate`, +`bun run ci:build`, `bun scripts/ci/audit.ts`와 전체 Git 기록 비밀정보 검사도 +통과했다. 실제 Chromium 전체 앱 시나리오 11개와 작업 보기 UX를 재검증했다. +CI 상태는 PR의 Verification 기록을 따른다. + +| 실제 UI 검사 | 증명한 범위 | +| --- | --- | +| `ui-desktop.mjs` | A→B→A 초안·첨부·읽기 위치, PNG 붙여넣기→업로드→명시적 전송→재조회, 한글 Undo, 작업 복원, 모바일·실패 복구 | +| `ui-navigation-regressions.mjs` | 모바일 뒤로가기·검색, 늦은 선택 취소, 저장소 차단·연결 단절 중 읽기 위치 | +| `ui-agent-list.mjs` | 목록 노드/포커스, 터치 취소, 표시 우선순위, 아바타 실패, 오류 알림 복구 | +| `ui-settings-islands.mjs` | 탭 방향·키보드·패널 유지, 테마 저장/시스템 변경과 저장소 실패 | +| `ui-model-combobox.mjs` | 검색·선택·disabled, Escape, dialog 내부 표시, 폭/스크롤/하단 배치, 실제 설정 저장 계약 | +| `ui-components.mjs` | 메뉴 키보드 진입, 이동, Tab/Shift+Tab, Escape, 반복 클릭, 바깥 클릭과 dialog 포커스 | +| `ui-task-layout.mjs` | 작업 탭의 보기 선택, 상태 이름, 실제 가용 폭, 모바일, 재배치와 복원 | +| `ui-design.mjs` | 320/390/768/1024/1440px, 버튼 정렬, 설정 탭과 다크/라이트 표면·잘림 | + +메뉴 Tab 종료와 PNG 중복 첨부처럼 브라우저에서 발견한 오류는 실패를 먼저 +기록하고 고쳤다. Bun의 minify만으로 React 개발 코드가 포함되는 오류도 +production 정의와 자산 테스트로 막았다. 의존성/소스 라이선스는 JS에 포함해 +웹과 Electron 양쪽 결과물에 보존한다. + +Linux ZIP을 생성했고 세 QA 서버의 JS/CSS/theme이 ASAR의 동봉 자산과 +바이트 단위로 일치했다. rendererVersion은 `8b7f0710d8fa18e241fa`다. +ZIP은 로컬 빌드 산출물이며 이 PR이 릴리스로 배포하지 않는다. + +## 격리와 재현 + +Bun과 별도로 Playwright/Chromium이 준비된 개발 환경에서 저장소 루트 기준으로 +실행한다. 실제 사용자 서버 대신 합성 fixture를 사용한다. + +```sh +# 별도 터미널: 루트/DB/첨부를 새 임시 디렉터리에 만드는 검증 서버 +LINA_QA_WEB_PORT=18140 LINA_QA_UPSTREAM_PORT=18142 \ + bun packages/lina-web/test/fixtures/ui-desktop.ts + +LINA_QA_URL=http://127.0.0.1:18140 node scripts/qa/ui-desktop.mjs +LINA_QA_URL=http://127.0.0.1:18140 node scripts/qa/ui-navigation-regressions.mjs +LINA_QA_URL=http://127.0.0.1:18140 node scripts/qa/ui-components.mjs +LINA_QA_URL=http://127.0.0.1:18140 node scripts/qa/ui-task-layout.mjs +bun scripts/qa/ui-agent-list.mjs +bun scripts/qa/ui-settings-islands.mjs +bun scripts/qa/ui-model-combobox.mjs +``` + +Playwright 모듈과 실행 파일은 `LINA_QA_PLAYWRIGHT_MODULE`, `LINA_QA_CHROMIUM`, +결과 디렉터리는 `LINA_QA_OUTPUT`으로 지정한다. `LINA_QA_FIRST_USER=1` fixture와 +`LINA_QA_INTRO_URL`로 처음 시작 화면을 검증할 수 있다. 화면 캡처용 모의 대화는 +`LINA_QA_PRESENTATION=1`로 선택한다. 작업이 시작한 검증 서버는 종료했다. + +## 남은 검증과 연결 + +- Electron GUI는 호스트 sandbox 조건 때문에 미검증이다. 정상 sandbox 환경의 + GUI·재시작·OS 파일 대화상자·알림·프로토콜 실행을 확인해야 한다. +- macOS/Windows 패키징·서명, 업데이트 배포, 물리 모바일 IME/OS 파일 복사는 + 별도 검증 범위다. Linux ZIP 생성은 출시/설치 검증을 뜻하지 않는다. +- 이미지 엔진의 `dev` 변경을 공통 renderer에 통합했다. 모의 생성·편집 결과가 + 세션 소유권을 유지한 320×200 미리보기로 표시되는 흐름을 확인했다. 엔진 내부를 + 수정하지 않았고 외부 provider 호출은 0회다. 실제 서비스의 생성·편집 품질과 + 세계관 엔진의 최종 연결은 이 검증으로 주장하지 않는다. +- 연속 발화·중간 입력의 엔진 정책은 [별도 설계](002_conversation_delivery.md)다. +- 기존 `render.ts ↔ attachments.ts` 참조는 남아 있다. 새 컴포넌트 경로는 + 순환하지 않으며, 기존 첨부 렌더링의 추가 분리는 이번 범위에 포함하지 않았다. + +PR 공개와 dev/main 머지, 배포는 별도 작업이다. 이 변경은 사용자 데이터나 +실행 중 서비스를 변경하지 않는다. diff --git a/docs/plans/codex-ui/010_ui_desktop.png b/docs/plans/codex-ui/010_ui_desktop.png new file mode 100644 index 0000000..c35c741 Binary files /dev/null and b/docs/plans/codex-ui/010_ui_desktop.png differ diff --git a/docs/plans/codex-ui/010_ui_evidence.json b/docs/plans/codex-ui/010_ui_evidence.json new file mode 100644 index 0000000..e47ea04 --- /dev/null +++ b/docs/plans/codex-ui/010_ui_evidence.json @@ -0,0 +1,14 @@ +{ + "capturedAt": "2026-09-07", + "kind": "actual web screenshots with synthetic fixture data", + "baseCommit": "cd74c89", + "rendererVersion": "8b7f0710d8fa18e241fa", + "screenshots": { + "010_ui_desktop.png": "ec0275c03bd92c888974ec49063e5194cb6709283be7344307fb0ecb79631e8b", + "010_ui_mobile.png": "da6b03621ca3c1dbe2fa3eca7b63190be9d0ddd61381e71f22bc816b9b100b53", + "010_ui_models.png": "d56dc4752bb01ac371c1696c082e32d2a79c70946c0521b3c0b1080bfec9e38b", + "010_ui_task_layout.png": "afb5b56c106c9f24df435c7f3c1d38c26ef0c06af5b1ff85010a41dba3a9adb8" + }, + "externalProviderCalls": 0, + "electronGuiVerified": false +} diff --git a/docs/plans/codex-ui/010_ui_mobile.png b/docs/plans/codex-ui/010_ui_mobile.png new file mode 100644 index 0000000..db29787 Binary files /dev/null and b/docs/plans/codex-ui/010_ui_mobile.png differ diff --git a/docs/plans/codex-ui/010_ui_models.png b/docs/plans/codex-ui/010_ui_models.png new file mode 100644 index 0000000..59fbcf0 Binary files /dev/null and b/docs/plans/codex-ui/010_ui_models.png differ diff --git a/docs/plans/codex-ui/010_ui_task_layout.png b/docs/plans/codex-ui/010_ui_task_layout.png new file mode 100644 index 0000000..e8304d7 Binary files /dev/null and b/docs/plans/codex-ui/010_ui_task_layout.png differ diff --git a/package.json b/package.json index 8a2bea6..1659e58 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "scripts": { "test": "bun test", - "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p packages/lina-web/client/tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p packages/lina-ui/client/tsconfig.json && bun run --cwd packages/lina-desktop typecheck", "lint": "biome check .", "lint:fix": "biome check --write .", "ci:validate": "bun scripts/ci/validate.ts", diff --git a/packages/lina-client/package.json b/packages/lina-client/package.json new file mode 100644 index 0000000..e9cc044 --- /dev/null +++ b/packages/lina-client/package.json @@ -0,0 +1,7 @@ +{ + "name": "@lina/client", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "Apache-2.0" +} diff --git a/packages/lina-client/src/agent-list.ts b/packages/lina-client/src/agent-list.ts new file mode 100644 index 0000000..620a446 --- /dev/null +++ b/packages/lina-client/src/agent-list.ts @@ -0,0 +1,224 @@ +import type { AgentProfile } from "../../lina-core/src/agents/types.ts"; +import type { + SessionSnapshot, + TimelineEntry, +} from "../../lina-core/src/protocol.ts"; +import { AttachmentDraft } from "./attachment-draft.ts"; + +export type AgentListProfile = Pick< + AgentProfile, + "id" | "name" | "role" | "avatarId" +>; +export type PublicListMessage = Pick< + TimelineEntry, + "entryId" | "seq" | "text" | "timestamp" +> & { role: "user" | "assistant" }; +export interface AgentConversationSummary { + agentId: string; + available: boolean; + sessionId: string | null; + revision: number; + latestMessage: PublicListMessage | null; + confirmationCount: number | null; + running: boolean | null; +} +type Cursor = { sessionId: string; seq: number }; +type CursorStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +}; +const cursorKey = (id: string) => `lina.seen.v1.${id}`; + +export function publicMessagePreview(message: { + role: "user" | "assistant"; + text: string; +}): string { + const attachment = new AttachmentDraft(); + attachment.value = message.text; + const text = attachment.text.trim(); + const preview = /^```|^~~~/.test(text) + ? "코드 답변" + : text || attachment.refs.map((ref) => ref.name).join(", "); + return `${message.role === "user" ? "나: " : ""}${preview.replace(/\s+/g, " ").slice(0, 160)}`; +} + +/** Input must be the public snapshot, never an engine transcript. */ +export function summaryFromSnapshot( + snapshot: SessionSnapshot, + confirmationCount: number | null = null, +): AgentConversationSummary { + let latestMessage: PublicListMessage | null = null; + for (const entry of snapshot.messages) { + if ( + (entry.role === "user" || entry.role === "assistant") && + (!latestMessage || entry.seq > latestMessage.seq) + ) { + latestMessage = { + entryId: entry.entryId, + seq: entry.seq, + role: entry.role, + text: entry.text, + timestamp: entry.timestamp, + }; + } + } + return { + agentId: snapshot.botId, + available: true, + sessionId: snapshot.sessionId, + revision: snapshot.revision, + latestMessage, + confirmationCount, + running: snapshot.state === "running", + }; +} + +/** Shared projection: server order, public summaries, device drafts and observed cursors. */ +export class AgentListModel { + private profiles: AgentListProfile[] = []; + private readonly summaries = new Map(); + private readonly unavailable = new Set(); + private readonly drafts = new Map(); + private readonly seen = new Map(); + private filter = ""; + status: "loading" | "ready" | "error" = "loading"; + constructor( + private currentId: string, + private readonly storage?: CursorStorage, + ) {} + + replace(profiles: AgentListProfile[]): void { + this.profiles = profiles; + this.status = "ready"; + const ids = new Set(profiles.map((profile) => profile.id)); + for (const id of this.summaries.keys()) + if (!ids.has(id)) this.summaries.delete(id); + for (const profile of profiles) { + if (this.seen.has(profile.id)) continue; + try { + const value: unknown = JSON.parse( + this.storage?.getItem(cursorKey(profile.id)) ?? "null", + ); + if ( + value && + typeof value === "object" && + "sessionId" in value && + typeof value.sessionId === "string" && + "seq" in value && + typeof value.seq === "number" && + Number.isSafeInteger(value.seq) && + value.seq >= 0 + ) + this.seen.set(profile.id, { + sessionId: value.sessionId, + seq: value.seq, + }); + } catch { + /* Storage is optional; unknown history is never marked unread. */ + } + } + } + summary(id: string): AgentConversationSummary | undefined { + return this.summaries.get(id); + } + setCurrent(id: string): void { + this.currentId = id; + } + setFilter(value: string): void { + this.filter = value.trim().toLocaleLowerCase(); + } + setDraft(id: string, value: string): void { + this.drafts.set(id, value); + } + fail(): void { + this.status = "error"; + } + summariesFailed(): void { + for (const profile of this.profiles) this.unavailable.add(profile.id); + } + updateSummary(summary: AgentConversationSummary): void { + if (!summary.available) { + this.unavailable.add(summary.agentId); + return; + } + const previous = this.summaries.get(summary.agentId); + if ( + previous?.sessionId === summary.sessionId && + previous.revision > summary.revision + ) + return; + this.summaries.set(summary.agentId, summary); + this.unavailable.delete(summary.agentId); + } + /** Call only for messages actually visible in the foreground conversation. */ + markVisible(agentId: string, sessionId: string, seq: number): void { + const summary = this.summaries.get(agentId); + if ( + !summary || + summary.sessionId !== sessionId || + !Number.isSafeInteger(seq) || + seq < 0 || + seq > (summary.latestMessage?.seq ?? 0) + ) + return; + const previous = this.seen.get(agentId); + const cursor = { + sessionId, + seq: + previous?.sessionId === sessionId ? Math.max(seq, previous.seq) : seq, + }; + this.seen.set(agentId, cursor); + try { + this.storage?.setItem(cursorKey(agentId), JSON.stringify(cursor)); + } catch { + /* Keep the cursor in memory. */ + } + } + rows() { + return this.profiles + .filter((profile) => + `${profile.name} ${profile.role}` + .toLocaleLowerCase() + .includes(this.filter), + ) + .map((profile) => { + const summary = this.summaries.get(profile.id); + const message = summary?.latestMessage; + const draft = this.drafts.get(profile.id)?.trim(); + const cursor = this.seen.get(profile.id); + const confirmationCount = summary?.confirmationCount ?? null; + const summaryUnavailable = this.unavailable.has(profile.id); + const preview = + confirmationCount && confirmationCount > 0 + ? `확인 필요 · ${confirmationCount}건` + : draft + ? `초안: ${publicMessagePreview({ role: "assistant", text: draft })}` + : message + ? publicMessagePreview(message) + : summaryUnavailable + ? "대화 요약을 불러오지 못했어요" + : summary + ? "대화를 시작해보세요" + : "대화 요약을 불러오는 중…"; + return { + ...profile, + selected: profile.id === this.currentId, + preview, + timestamp: message?.timestamp ?? null, + sessionId: summary?.sessionId ?? null, + messageSequence: message?.seq ?? null, + confirmationCount, + summaryUnavailable, + running: summary?.running === true, + unread: Boolean( + message?.role === "assistant" && + cursor?.sessionId === summary?.sessionId && + message && + cursor && + message.seq > cursor.seq, + ), + }; + }); + } +} +export type AgentListRow = ReturnType[number]; diff --git a/packages/lina-web/client/attachment-draft.ts b/packages/lina-client/src/attachment-draft.ts similarity index 87% rename from packages/lina-web/client/attachment-draft.ts rename to packages/lina-client/src/attachment-draft.ts index d113473..7180be5 100644 --- a/packages/lina-web/client/attachment-draft.ts +++ b/packages/lina-client/src/attachment-draft.ts @@ -1,4 +1,5 @@ export type DraftAttachment = { id: string; name: string; sessionId: string }; +export const MAX_DRAFT_ATTACHMENTS = 4; const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const LINE = new RegExp( `^\\[([^\\[\\]<>\\x00-\\x1f\\\\/]{1,120})\\]\\(/api/attachments/(${UUID})\\?sessionId=(${UUID})\\)$`, @@ -42,7 +43,7 @@ export class AttachmentDraft { lines.pop(); if (lines.at(-1) === "") lines.pop(); } - if (refs.length > 4) { + if (refs.length > MAX_DRAFT_ATTACHMENTS) { this.text = value; this.items = []; } else { @@ -55,7 +56,7 @@ export class AttachmentDraft { value, }; } - add(ref: DraftAttachment): void { + add(ref: DraftAttachment, index = this.items.length): void { if ( this.items.some( (item) => item.id === ref.id && item.sessionId === ref.sessionId, @@ -64,9 +65,10 @@ export class AttachmentDraft { return; if (!LINE.test(attachmentLink(ref))) throw new Error("유효하지 않은 첨부파일"); - if (this.items.length >= 4) throw new Error("첨부 한도 초과 · 최대 4개"); + if (this.items.length >= MAX_DRAFT_ATTACHMENTS) + throw new Error("첨부 한도 초과 · 최대 4개"); const previous = this.items; - this.items = [...previous, ref]; + this.items = [...previous.slice(0, index), ref, ...previous.slice(index)]; if (this.value.length > 16000) { this.items = previous; throw new Error("첨부 공간 부족 · 메시지 길이 줄이기"); diff --git a/packages/lina-client/src/attachment-queue.ts b/packages/lina-client/src/attachment-queue.ts new file mode 100644 index 0000000..32662ee --- /dev/null +++ b/packages/lina-client/src/attachment-queue.ts @@ -0,0 +1,300 @@ +import { + type AttachmentDraft, + type DraftAttachment, + MAX_DRAFT_ATTACHMENTS, +} from "./attachment-draft.ts"; + +// Client admission mirrors the server's current byte limit; server still validates bytes. +export const ATTACHMENT_UPLOAD_MAX_BYTES = 2 * 1024 * 1024; +export type AttachmentSource = "picker" | "paste"; +type Item = { + key: string; + name: string; + owner: string; + source: AttachmentSource; +}; +type LocalItem = Item & { + file: File; + state: "pending" | "uploading" | "error"; + error?: string; +}; +export type AttachmentQueueItem = + | LocalItem + | (Item & { state: "ready"; ref: DraftAttachment }); +type Upload = ( + file: File, + owner: string, + signal: AbortSignal, +) => Promise; +type Options = { + session: () => string | undefined; + text: () => string; + changed: () => void; + notice: (message: string) => void; + upload?: Upload; +}; +type Flight = { + item: LocalItem; + generation: number; + controller: AbortController; +}; + +/** In-memory file queue. Only ready refs enter the existing serializable draft. */ +export class AttachmentQueue { + private entries: AttachmentQueueItem[] = []; + private active: Flight | undefined; + private generation = 0; + private sequence = 0; + private owner: string | undefined; + private online = false; + constructor( + private readonly draft: AttachmentDraft, + private readonly options: Options, + ) { + this.owner = options.session(); + } + get items(): readonly AttachmentQueueItem[] { + return this.entries; + } + get busy(): boolean { + return this.active !== undefined; + } + get blocked(): boolean { + return this.entries.some( + (item) => item.state !== "ready" || item.owner !== this.options.session(), + ); + } + reset(value: string): void { + this.generation++; + this.cancel(); + this.owner = this.options.session(); + this.draft.value = value; + this.entries = this.draft.refs.map((ref) => ({ + key: this.key(), + name: ref.name, + owner: ref.sessionId, + source: "picker", + state: "ready", + ref, + })); + // The value setter's caller owns persistence and its update cycle. + } + update(online: boolean): void { + const owner = this.options.session(); + const changedOwner = owner !== this.owner; + const disconnected = this.online && !online; + this.online = online; + if (changedOwner || disconnected) { + this.owner = owner; + this.generation++; + for (const item of this.entries) { + if ( + item.state === "ready" || + (!changedOwner && item.state !== "uploading") + ) + continue; + item.state = "error"; + item.error = changedOwner + ? "대화가 바뀌었어요 · 제외 후 다시 첨부" + : "연결이 끊겼어요 · 연결 후 재시도"; + } + this.cancel(); + } + this.pump(); + } + addFiles(files: readonly File[], source: AttachmentSource): void { + this.update(this.online); + if (!files.length) return; + const owner = this.owner; + if (!owner) { + this.options.notice("대화 연결 후 파일을 다시 첨부해 주세요."); + return; + } + if (files.length + this.entries.length > MAX_DRAFT_ATTACHMENTS) { + this.options.notice( + `첨부 한도 초과 · 최대 4개 · 이번 ${files.length}개는 추가되지 않았어요.`, + ); + return; + } + for (const input of files) { + const file = namedFile(input); + this.entries.push({ + key: this.key(), + name: file.name || "이름 없는 파일", + owner, + source, + state: "pending", + file, + }); + } + this.pump(); + this.options.changed(); + } + retry(key: string): void { + this.update(this.online); + const item = this.entries.find((item) => item.key === key); + if (item?.state !== "error") return; + if (item.owner !== this.owner) { + this.options.notice( + "다른 대화의 파일이에요 · 제외 후 다시 첨부해 주세요.", + ); + return; + } + if (!this.online) { + this.options.notice("연결 후 다시 시도해 주세요."); + return; + } + item.state = "pending"; + delete item.error; + this.pump(); + this.options.changed(); + } + remove(key: string): void { + const item = this.entries.find((item) => item.key === key); + if (!item) return; + this.entries = this.entries.filter((item) => item.key !== key); + if (item.state === "ready") this.draft.remove(item.ref.id); + if (this.active?.item === item) this.cancel(); + this.pump(); + this.options.changed(); + } + private key(): string { + return `attachment-${++this.sequence}`; + } + private cancel(): void { + const previous = this.active; + this.active = undefined; + previous?.controller.abort(); + } + private pump(): void { + if (this.active || !this.online) return; + const item = this.entries.find( + (item): item is LocalItem => + item.state === "pending" && item.owner === this.owner, + ); + if (!item) return; + item.state = "uploading"; + const flight = { + item, + generation: this.generation, + controller: new AbortController(), + }; + this.active = flight; + void this.upload(flight); + } + private current(flight: Flight): boolean { + return ( + this.active === flight && + this.generation === flight.generation && + this.options.session() === flight.item.owner + ); + } + private async upload(flight: Flight): Promise { + const { item } = flight; + try { + if (item.file.size > ATTACHMENT_UPLOAD_MAX_BYTES) + throw Error("파일 크기 초과 · 최대 2 MiB"); + const ref = await (this.options.upload ?? uploadAttachment)( + item.file, + item.owner, + flight.controller.signal, + ); + if (!this.current(flight)) return; + if (ref.sessionId !== item.owner) + throw Error("첨부 결과 미확인 · 재시도 또는 제외해 주세요."); + this.draft.text = this.options.text(); + const index = this.entries.indexOf(item); + this.draft.add( + ref, + this.entries.slice(0, index).filter((item) => item.state === "ready") + .length, + ); + this.entries[index] = { + key: item.key, + name: ref.name, + owner: item.owner, + source: item.source, + state: "ready", + ref, + }; + } catch (error) { + if (!this.current(flight)) return; + item.state = "error"; + item.error = + error instanceof Error + ? error.message + : "첨부 실패 · 재시도 또는 제외해 주세요."; + } finally { + if (this.active === flight) { + this.active = undefined; + this.update(this.online); + this.options.changed(); + } + } + } +} + +function namedFile(file: File): File { + if (file.name?.trim() && !["image", "blob"].includes(file.name)) return file; + const extension = + file.type === "image/png" + ? "png" + : file.type === "image/jpeg" + ? "jpg" + : undefined; + return extension + ? new File([file], `clipboard-${crypto.randomUUID()}.${extension}`, { + type: file.type, + lastModified: file.lastModified, + }) + : file; +} + +async function uploadAttachment( + file: File, + owner: string, + signal: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch("/api/attachments", { + method: "POST", + headers: { + "X-Lina-Session": owner, + "X-Lina-Filename": encodeURIComponent(file.name), + "Content-Type": "application/octet-stream", + }, + body: file, + signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]), + redirect: "error", + }); + } catch { + throw Error("첨부 결과 미확인 · 연결 확인 후 재시도 또는 제외해 주세요."); + } + if (!response.ok) { + if (response.status === 413) throw Error("파일 크기 초과 · 최대 2 MiB"); + if (response.status === 507) + throw Error("첨부 저장 공간 부족 · 공간 확보 후 재시도"); + if (response.status >= 500) + throw Error("첨부 저장소 오류 · 서버 상태 확인 후 재시도"); + throw Error( + "첨부 실패 · 파일명·형식·이미지 크기를 확인해 주세요. PNG·JPEG·PDF·DOCX·XLSX·텍스트 지원", + ); + } + let meta: unknown; + try { + meta = await response.json(); + } catch { + throw Error("첨부 결과 미확인 · 재시도 또는 제외해 주세요."); + } + if ( + typeof meta !== "object" || + !meta || + !("id" in meta) || + typeof meta.id !== "string" || + !("name" in meta) || + typeof meta.name !== "string" + ) + throw Error("첨부 결과 미확인 · 재시도 또는 제외해 주세요."); + return { id: meta.id, name: meta.name, sessionId: owner }; +} diff --git a/packages/lina-web/client/connection.ts b/packages/lina-client/src/connection.ts similarity index 98% rename from packages/lina-web/client/connection.ts rename to packages/lina-client/src/connection.ts index 6232c0f..4fd69e3 100644 --- a/packages/lina-web/client/connection.ts +++ b/packages/lina-client/src/connection.ts @@ -1,6 +1,6 @@ import type { ContextClient } from "../../lina-core/src/context-wire.ts"; import type { ChatCommand, WireClient } from "../../lina-core/src/wire.ts"; -import { parseServerFrame, type ServerFrame } from "../src/protocol.ts"; +import { parseServerFrame, type ServerFrame } from "./protocol.ts"; export type ConnectionEvents = { readonly frame: (frame: ServerFrame) => void; diff --git a/packages/lina-web/client/context-model.ts b/packages/lina-client/src/context-model.ts similarity index 100% rename from packages/lina-web/client/context-model.ts rename to packages/lina-client/src/context-model.ts diff --git a/packages/lina-web/client/draft.ts b/packages/lina-client/src/draft.ts similarity index 100% rename from packages/lina-web/client/draft.ts rename to packages/lina-client/src/draft.ts diff --git a/packages/lina-web/client/durable-model.ts b/packages/lina-client/src/durable-model.ts similarity index 99% rename from packages/lina-web/client/durable-model.ts rename to packages/lina-client/src/durable-model.ts index 417e2da..4c339d9 100644 --- a/packages/lina-web/client/durable-model.ts +++ b/packages/lina-client/src/durable-model.ts @@ -3,8 +3,8 @@ import type { SessionSnapshot, TimelineEntry, } from "../../lina-core/src/protocol.ts"; -import type { ServerFrame } from "../src/protocol.ts"; import { type ChatMessage, ChatModel } from "./model.ts"; +import type { ServerFrame } from "./protocol.ts"; type Expanded = { text: string; diff --git a/packages/lina-web/client/execution-model.ts b/packages/lina-client/src/execution-model.ts similarity index 100% rename from packages/lina-web/client/execution-model.ts rename to packages/lina-client/src/execution-model.ts diff --git a/packages/lina-web/client/model.ts b/packages/lina-client/src/model.ts similarity index 97% rename from packages/lina-web/client/model.ts rename to packages/lina-client/src/model.ts index 79871ac..692c7a3 100644 --- a/packages/lina-web/client/model.ts +++ b/packages/lina-client/src/model.ts @@ -1,5 +1,5 @@ import type { ToolMetadata } from "../../lina-core/src/protocol.ts"; -import { MAX_CHAT_LENGTH, type ServerFrame } from "../src/protocol.ts"; +import { MAX_CHAT_LENGTH, type ServerFrame } from "./protocol.ts"; export type MessageStatus = | "pending" diff --git a/packages/lina-client/src/navigation-state.ts b/packages/lina-client/src/navigation-state.ts new file mode 100644 index 0000000..5d69daf --- /dev/null +++ b/packages/lina-client/src/navigation-state.ts @@ -0,0 +1,44 @@ +export type Screen = "agents" | "conversation" | "tasks" | "settings"; +export type NavigationState = { + screen: Screen; + agentId: string; + taskId?: string; +}; +const AGENT_ID = /^[a-z][a-z0-9-]{0,47}$/; + +export function parseNavigation(href: string): NavigationState { + const query = new URL(href).searchParams; + const id = query.get("agent"); + const valid = id !== null && AGENT_ID.test(id); + const screen = query.get("screen"); + const task = query.get("task"); + if (task && /^[a-zA-Z0-9_-]{1,128}$/.test(task)) + return { agentId: valid ? id : "lina", screen: "tasks", taskId: task }; + return { + agentId: valid ? id : "lina", + screen: + screen === "agents" || + screen === "tasks" || + screen === "settings" || + screen === "conversation" + ? screen + : valid + ? "conversation" + : "agents", + }; +} + +export function navigationUrl(state: NavigationState): string { + return `/?agent=${encodeURIComponent(state.agentId)}&screen=${state.screen}${state.taskId ? `&task=${encodeURIComponent(state.taskId)}` : ""}`; +} + +export function navigationLayout( + width: number, + expandedTasks: boolean, + sidebarWidth: number, +): "mobile" | "single" | "dual" { + if (width < 768) return "mobile"; + return expandedTasks && width >= Math.max(1440, sidebarWidth + 280 + 720 + 32) + ? "dual" + : "single"; +} diff --git a/packages/lina-client/src/platform.ts b/packages/lina-client/src/platform.ts new file mode 100644 index 0000000..7eb2c5f --- /dev/null +++ b/packages/lina-client/src/platform.ts @@ -0,0 +1,13 @@ +/** Both browser and desktop broker expose the same origin-scoped resource contract. */ +export function socketUrl(origin: string, path: string): string { + const url = new URL(path, origin); + if ( + url.origin !== new URL(origin).origin || + !["http:", "https:"].includes(url.protocol) + ) + throw new Error("Invalid Lina socket origin"); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + +export type DesktopPlatform = { readonly platform: "desktop" }; diff --git a/packages/lina-web/client/preferences.ts b/packages/lina-client/src/preferences.ts similarity index 98% rename from packages/lina-web/client/preferences.ts rename to packages/lina-client/src/preferences.ts index 45fef20..241d7d1 100644 --- a/packages/lina-web/client/preferences.ts +++ b/packages/lina-client/src/preferences.ts @@ -1,6 +1,6 @@ export const SIDEBAR_MIN_WIDTH = 200; export const SIDEBAR_MAX_WIDTH = 420; -export const SIDEBAR_DEFAULT_WIDTH = 256; +export const SIDEBAR_DEFAULT_WIDTH = 304; export function normalizeSidebarWidth(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.max( diff --git a/packages/lina-client/src/protocol.ts b/packages/lina-client/src/protocol.ts new file mode 100644 index 0000000..223aba2 --- /dev/null +++ b/packages/lina-client/src/protocol.ts @@ -0,0 +1,79 @@ +export const MAX_CHAT_LENGTH = 16_000; +export type ChatFrame = { + readonly type: "chat"; + readonly id: string; + readonly text: string; +}; +export type ServerFrame = + | ContextServer + | ControlServer + | WireServer + | { readonly type: "ack"; readonly id: string } + | { readonly type: "error"; readonly message: string } + | { readonly type: "agent-text" | "agent-thinking"; readonly text: string } + | { readonly type: "agent-status"; readonly state: "running" | "idle" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function record(raw: unknown): Record | undefined { + if (typeof raw !== "string" || raw.length > 1_048_576) return; + try { + const value: unknown = JSON.parse(raw); + if (isRecord(value)) return value; + } catch { + /* Invalid network frames carry no trusted data. */ + } + return; +} + +export function parseChat(raw: unknown): ChatFrame | undefined { + const value = parseWireClient(raw); + return value?.type === "chat" ? value : undefined; +} + +export function parseServerFrame(raw: unknown): ServerFrame | undefined { + const context = parseContextServer(raw); + if (context) return context; + const control = parseControlServer(raw); + if (control) return control; + const durable = parseWireServer(raw); + if (durable) return durable; + const value = record(raw); + if (value === undefined) return; + const { type, id, message, text, state } = value; + switch (type) { + case "ack": + return typeof id === "string" && id.length > 0 + ? { type: "ack", id } + : undefined; + case "error": + return typeof message === "string" + ? { type: "error", message } + : undefined; + case "agent-text": + case "agent-thinking": + return typeof text === "string" ? { type, text } : undefined; + case "agent-status": + return state === "idle" || state === "running" + ? { type: "agent-status", state } + : undefined; + default: + return; + } +} + +import { + type ContextServer, + parseContextServer, +} from "../../lina-core/src/context-wire.ts"; +import { + type ControlServer, + parseControlServer, +} from "../../lina-core/src/control-wire.ts"; +import { + parseWireClient, + parseWireServer, + type WireServer, +} from "../../lina-core/src/wire.ts"; diff --git a/packages/lina-web/client/search-model.ts b/packages/lina-client/src/search-model.ts similarity index 100% rename from packages/lina-web/client/search-model.ts rename to packages/lina-client/src/search-model.ts diff --git a/packages/lina-client/src/task-drafts.ts b/packages/lina-client/src/task-drafts.ts new file mode 100644 index 0000000..3c36c80 --- /dev/null +++ b/packages/lina-client/src/task-drafts.ts @@ -0,0 +1,98 @@ +export type TaskDraft = { input: string; owner?: string }; +export type TaskStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; +const key = (id: string) => `lina.task-draft.v1.${id}`; +const SCOPE = "lina.task-scope.v1"; + +/** Device state only; failed writes keep the live draft and block destructive departure. */ +export class TaskDraftStore { + private readonly cache = new Map(); + private readonly dirty = new Set(); + private readonly stored = new Set(); + private selectedScope: "selected" | "all"; + constructor(private readonly storage?: TaskStorage) { + try { + this.selectedScope = + storage?.getItem(SCOPE) === "all" ? "all" : "selected"; + } catch { + this.selectedScope = "selected"; + } + } + get(id: string): TaskDraft | undefined { + if (this.cache.has(id)) return this.cache.get(id); + let draft: TaskDraft | undefined; + try { + const raw = this.storage?.getItem(key(id)); + const value: unknown = + raw && raw.length <= 262144 ? JSON.parse(raw) : null; + if ( + value && + typeof value === "object" && + "input" in value && + typeof value.input === "string" && + value.input.length <= 65536 + ) { + const owner = "owner" in value ? value.owner : undefined; + if ( + owner === undefined || + (typeof owner === "string" && /^[a-z][a-z0-9-]{0,47}$/.test(owner)) + ) + draft = { input: value.input, ...(owner ? { owner } : {}) }; + } + } catch { + /* Untrusted optional device state cannot replace validated server data. */ + } + if (draft) this.stored.add(id); + this.cache.set(id, draft); + return draft; + } + set(id: string, draft: TaskDraft): boolean { + this.get(id); + if (!draft.input && !draft.owner) return this.delete(id); + this.cache.set(id, { ...draft }); + this.dirty.add(id); + return this.persist(id); + } + delete(id: string): boolean { + if (!this.get(id) && !this.dirty.has(id)) return true; + this.cache.set(id, undefined); + if (!this.stored.has(id)) { + this.dirty.delete(id); + return true; + } + this.dirty.add(id); + return this.persist(id); + } + private persist(id: string): boolean { + if (!this.storage) return false; + try { + const draft = this.cache.get(id); + if (draft) this.storage.setItem(key(id), JSON.stringify(draft)); + else this.storage.removeItem(key(id)); + if (draft) this.stored.add(id); + else this.stored.delete(id); + this.dirty.delete(id); + return true; + } catch { + return false; + } + } + flush(): boolean { + for (const id of this.dirty) this.persist(id); + return this.dirty.size === 0; + } + scope(): "selected" | "all" { + return this.selectedScope; + } + setScope(scope: "selected" | "all"): void { + this.selectedScope = scope; + try { + this.storage?.setItem(SCOPE, scope); + } catch { + /* The selected filter remains in memory. */ + } + } +} diff --git a/packages/lina-client/src/task-types.ts b/packages/lina-client/src/task-types.ts new file mode 100644 index 0000000..be31a0d --- /dev/null +++ b/packages/lina-client/src/task-types.ts @@ -0,0 +1,38 @@ +export type TaskApproval = { + id: string; + method: string; + params: unknown; + createdAt: string; +}; + +export type ApprovalDecision = + | "accept" + | "decline" + | "cancel" + | "acceptForSession"; + +export type TaskRecord = { + id: string; + threadId: string | null; + ownerAgentId: string; + title: string; + cwd: string; + status: string; + revision: number; + updatedAt: string; + model: string | null; + pendingApprovals: TaskApproval[]; +}; + +export function taskStatusLabel(status: string): string { + if (status === "inProgress" || status === "running") return "진행 중"; + if (status === "waiting_approval") return "확인 대기"; + if (status === "waiting_input") return "입력 대기"; + if (status === "needs_attention") return "확인 필요"; + if (status === "creating") return "만드는 중"; + if (status === "completed") return "완료"; + if (status === "interrupted") return "중단됨"; + if (status === "failed") return "실패"; + if (status === "queued" || status === "idle") return "대기"; + return status; +} diff --git a/packages/lina-desktop/.gitignore b/packages/lina-desktop/.gitignore new file mode 100644 index 0000000..8c559c7 --- /dev/null +++ b/packages/lina-desktop/.gitignore @@ -0,0 +1,3 @@ +dist/ +out/ +.qa/ diff --git a/packages/lina-desktop/README.md b/packages/lina-desktop/README.md new file mode 100644 index 0000000..633daa5 --- /dev/null +++ b/packages/lina-desktop/README.md @@ -0,0 +1,216 @@ +# Lina desktop + +An Electron client for an independently running Lina web server. The app bundles +exactly the renderer built by `lina-web/src/assets.ts`; it does not start an +execution engine, web development server, or Bun at runtime. + +## Build and run + +From the repository root, install the committed workspace lockfile with lifecycle +scripts disabled, then build or package: + +```sh +bun install --frozen-lockfile --ignore-scripts +bun run --cwd packages/lina-desktop build +bun run --cwd packages/lina-desktop package +bun run --cwd packages/lina-desktop make +``` + +`package` produces the application directory under `out/`; `make` additionally +produces a ZIP for the current platform. Neither command publishes, signs, installs +an OS service, or changes a running Lina server. Forge downloads Electron as part +of packaging; no trusted install hook is added. Use Node >=22.12 for Forge. + +Linux example, connecting to an explicitly selected Lina web gateway: + +```sh +LINA_DESKTOP_SERVER_URL=http://127.0.0.1:18140 \ +LINA_DESKTOP_PORT=18141 \ +LINA_DESKTOP_USER_DATA=/tmp/lina-desktop-example/desktop \ +packages/lina-desktop/out/Lina-linux-x64/lina-desktop +``` + +For development, `bun run --cwd packages/lina-desktop start` uses an already +provisioned Electron binary. An ignored install script is not permission to turn +on dependency hooks. Packaging provides a separate executable without such a hook. + +| Setting | Meaning | +| --- | --- | +| `LINA_DESKTOP_SERVER_URL` | Required exact `http://127.0.0.1:PORT` or `http://[::1]:PORT`; points to Lina **web**, not the intervention socket | +| `LINA_DESKTOP_PORT` | Stable broker port, default `43127`; QA reserves `18141` | +| `LINA_DESKTOP_USER_DATA` | Absolute isolated profile path; default `$LINA_HOME/desktop` or `~/.lina/desktop` | +| `LINA_DESKTOP_EXTERNAL_ORIGINS` | Comma-separated exact HTTPS origins allowed in the OS browser; default `https://github.com`; an empty value denies all | + +The app never falls back to another broker port. `connection.json` binds the +profile to its configured server and broker port. Reusing a profile with either +changed is rejected; use a separate profile to avoid mixing old drafts with a +different server. Electron session data lives in the profile's `sessions/` +directory and the `persist:lina-desktop` partition. The server setting must be +provided on each launch; it is not inferred from open ports or renderer input. + +## Transport and process boundary + +The renderer uses its ordinary same-origin fetch and `/ws` contracts: + +```text +shared renderer → authenticated 127.0.0.1 broker → pinned Lina web gateway + Electron main existing Host/Origin checks +``` + +The existing server creates browser assets with Bun. A `file://` renderer would +break its same-origin API, attachment and WS paths, while loading remote server +HTML would make the desktop shell unavailable offline. This package instead +writes the existing `loadWebAssets()` output to a checksummed disk manifest and +serves it from main's loopback broker. Only web/PWA metadata and service-worker +files are omitted. Shared model/parser code remains owned by `lina-client` and +Lina web; no copied engine or wire model lives in this package. + +`src/config.ts` validates operator configuration; `session.ts` owns the ephemeral +credential and persistent profile binding; `routes.ts` is the desktop egress +allowlist; `broker.ts`, `http-proxy.ts`, and `socket-proxy.ts` implement Node +transport; `window.ts` owns Electron/OS policy. Bun-dependent build code stays in +`scripts/`. Runtime code only imports Node builtins and Electron. + +The broker accepts only a main-generated 256-bit credential installed directly +with `session.cookies.set` before navigation. No HTTP route issues or returns it. +The cookie is host-only, HttpOnly, SameSite=Strict, and session-only; it rotates +on every main-process start. `secure: false` is intentional for the literal +loopback HTTP origin. Cookies have no port boundary, so Electron blocks requests +to every other origin before networking; the broker separately validates exact +Host and Origin. The cookie is never forwarded upstream or exposed by preload. + +HTTP forwarding validates raw path, method, query, and bounded bodies. It allows +only the existing attachment, agent, model, hub, task, onboarding and intro +contracts. It sets upstream Host/Origin for the pinned destination, rejects all +redirects, and forwards only required metadata. Upstream Set-Cookie and Location +are dropped. Attachment authorization and domain schemas still run on Lina. + +WS forwarding validates the cookie, exact Origin, `/ws?agent=...`, handshake +version/key and upstream accept value. It does not follow redirects or negotiate +extensions/subprotocols. Node streams provide backpressure; the existing Lina web +gateway retains frame parsing and payload limits. At most eight desktop tunnels +are accepted. Closing the window/app closes transport; it does not stop server +work. HTTP failures return 502; existing shared-renderer retry and WS reconnect +behavior can recover while bundled assets remain available. + +## OS surface + +The window enables `sandbox`, `contextIsolation`, and `webSecurity`, disables Node +integration/webviews, denies permission requests and foreign navigation, and +installs the normal Edit/View/Window menus. One instance owns each profile. +macOS keeps the app alive when its window closes and reopens it on activation; +other platforms quit when all windows close. Quitting flushes browser storage and +closes broker connections. + +Preload exposes only: + +```ts +window.linaDesktop = { + platform: "desktop", + openExternal(url: string): Promise, + notify(input: { title: string; body: string }): Promise +} +``` + +Every OS IPC call checks the owning webContents and main frame, validates input, +and uses a dedicated channel. There is no general IPC, file-path read, shell +command, credential, or transport API. Shared UI uses the marker to skip PWA +registration. Native file inputs and ordinary attachment downloads keep their +existing browser behavior; validated attachment downloads use the native save +dialog. Notifications accept only bounded title/body text. The existing “Codex에서 열기” +control opens only `codex://threads/` with 1–128 ASCII letters, digits, +underscores or hyphens. Validation checks the raw URI before normalization; +commands, nested/encoded paths, credentials, ports, query and fragment are denied. +This opens the registered OS handler for that existing thread and never creates +another task. A missing/broken handler produces a native error dialog and rejects +an IPC request. HTTPS links still require exact allowlisted origins; register the +OpenCodex hub's HTTPS origin, including a nondefault port, explicitly when needed. App updates, signing, +IME/paste qualification and additional native OS capabilities need separate proof. + +## Threat model and verification + +Assets are local conversations/drafts, uploaded files and the broker credential. +Entry points are HTTP/WS headers and paths, renderer IPC, navigation, downloads and +operator configuration. A hostile website or forged local browser request must +not reach Lina through the desktop broker, learn its token, select another +upstream, or gain arbitrary OS capabilities. The explicitly configured Lina web +service and the OS account running main are trusted. This is not isolation from +a malicious process already able to read that account's memory/profile files. + +Must-pass checks cover missing/duplicate/wrong cookies, Host/Origin mismatch, +cross-port requests, path/query/method rejection, redirects and invalid WS +handshakes, metadata preservation, body bounds, offline recovery, profile binding, +IPC sender/subframe rejection, and the absence of broad preload capabilities. +Tests use real loopback servers and explicit events, with no sleep or provider +calls. `web-contract.test.ts` traverses the actual unchanged Lina web gateway; +`node-runtime.test.ts` builds and runs the broker in a separate Node process. + +```sh +bun test packages/lina-desktop/test +bun run --cwd packages/lina-desktop typecheck +bunx --no-install biome check packages/lina-desktop --diagnostic-level=warn +bun run --cwd packages/lina-desktop package +bun scripts/ci/audit.ts +``` + +Runtime TypeScript uses DOM + Electron's referenced Node declarations. Build/tests +use the repository's Bun configuration in `tsconfig.build.json`. The root compiler +must exclude this package and call its separate check, avoiding Electron's DOM +fetch declarations leaking into Bun server source. + +## Version evidence and qualification + +Checked 2026-09-07: [Electron stable releases](https://releases.electronjs.org/?channel=stable) +listed **44.2.0**, also verified against installed package metadata and the Linux +artifact's `version` file. [Forge 8.0.0-alpha.10](https://github.com/electron/forge/releases/tag/v8.0.0-alpha.10) +is explicitly a **prerelease**. Its official change list replaces extract-zip, +and its registry metadata moves to packager 20/rebuild 4. It is selected because +the latest stable Forge 7.11.2 introduced failing repository audit findings in +extract-zip 2.0.1, tar 6.2.1 and tmp 0.0.33. The +[extract-zip advisory](https://github.com/advisories/GHSA-jmr9-qjv8-65gv) +has no fixed version under that package name. No audit finding is suppressed. The final installed graph used packager **20.3.0**, +rebuild **4.2.0**, electron-internal/extract-zip **1.0.5**, and tar **7.5.22**; +extract-zip and tmp were absent. `bun scripts/ci/audit.ts` passed both the lockfile +check (135 packages) and installed-package check (129 names). No root overrides +were needed. Obsolete Forge 7 store entries were preserved outside `node_modules` +after checking the new lockfile and reachable dependency links; this avoids stale +packages contaminating the installed inventory without changing audit policy. +Forge 8 `package` and `make` both completed on Linux x64. The executable basename +was verified as `out/Lina-linux-x64/lina-desktop`; ZIP output is +`out/make/zip/linux/x64/Lina-linux-x64-0.1.0.zip`. + +Quit cleanup runs on `will-quit`, after all windows have closed, so a renderer +that vetoes closing for an unfinished upload retains its transport. +`lifecycle.test.ts` exercises the compiled main entry with controlled Electron +boundaries; this does not replace the remaining graphical acceptance. See the +[app lifecycle](https://www.electronjs.org/docs/latest/api/app#event-will-quit). + +Security API references: [Electron security](https://www.electronjs.org/docs/latest/tutorial/security), +[cookie API](https://www.electronjs.org/docs/latest/api/cookies), and +[session API](https://www.electronjs.org/docs/latest/api/session). + +Initial RED runs failed on the absent `config.ts`, `broker.ts`, bundle writer and +`window-policy.ts` before their implementation. A later real contract RED returned +405 for onboarding draft PATCH; fixing that route and memory refresh POST restored +the transport suite. The Codex thread link test then failed with “Only HTTPS links +without credentials are allowed”; the strict thread exception passed the same +positive/negative test. An OS-handler failure initially produced no dialog; the +shared native-error boundary made that regression pass. + +The native Node regression also reproduced an unhandled `write ECONNRESET` when +an unauthenticated upgrade peer called `resetAndDestroy()` immediately after its +write. No upstream connection is reached on that path, isolating the fault to the +rejection socket after Node removes its HTTP error listener. `rejectUpgrade` now +installs its error listener before every rejection write. Repeating those TCP +resets and then a successful authenticated HTTP request passes in Node and the +packaged Electron Node runtime. Scope-local verification results are recorded in the executor +handoff; repeat them after shared-renderer or dependency changes. + +The first Linux graphical launch reached Chromium but aborted because the +`chrome-sandbox` helper lacked root ownership/mode 4755. `unshare -Ur true` also +failed with `Operation not permitted` on this host. No sandbox flag, host policy, +or privileged permission was changed. Therefore Linux graphical UI, browser +storage across a real Electron restart, OS dialogs and notifications are **not yet +qualified here**. The packaged Electron executable did run the compiled broker +successfully with `ELECTRON_RUN_AS_NODE=1`; that proves its Node transport runtime, +not the sandboxed renderer. macOS/Windows launch and packaging remain untested. diff --git a/packages/lina-desktop/biome.json b/packages/lina-desktop/biome.json new file mode 100644 index 0000000..63b7d67 --- /dev/null +++ b/packages/lina-desktop/biome.json @@ -0,0 +1,5 @@ +{ + "root": false, + "extends": "//", + "files": { "includes": ["**", "!**/dist", "!**/out", "!**/.qa"] } +} diff --git a/packages/lina-desktop/forge.config.cjs b/packages/lina-desktop/forge.config.cjs new file mode 100644 index 0000000..d50aad6 --- /dev/null +++ b/packages/lina-desktop/forge.config.cjs @@ -0,0 +1,21 @@ +// Forge is a development tool. The packaged application contains only dist + package metadata. +module.exports = { + packagerConfig: { + asar: true, + name: "Lina", + executableName: "lina-desktop", + appBundleId: "app.lina.desktop", + prune: true, + ignore: (path) => + path !== "" && + path !== "/package.json" && + path !== "/dist" && + !path.startsWith("/dist/"), + }, + makers: [ + { + name: "@electron-forge/maker-zip", + platforms: ["darwin", "linux", "win32"], + }, + ], +}; diff --git a/packages/lina-desktop/package.json b/packages/lina-desktop/package.json new file mode 100644 index 0000000..ec3f756 --- /dev/null +++ b/packages/lina-desktop/package.json @@ -0,0 +1,26 @@ +{ + "name": "@lina/desktop", + "productName": "Lina", + "version": "0.1.0", + "private": true, + "description": "Bundled Lina desktop client; connects to an independently running Lina web server", + "license": "Apache-2.0", + "author": "Lina contributors", + "main": "dist/main.cjs", + "scripts": { + "build": "bun scripts/build.ts", + "start": "electron .", + "package": "bun run build && electron-forge package", + "make": "bun run build && electron-forge make", + "test": "bun test test", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.build.json" + }, + "devDependencies": { + "electron": "44.2.0", + "@electron-forge/cli": "8.0.0-alpha.10", + "@electron-forge/maker-zip": "8.0.0-alpha.10" + }, + "config": { + "forge": "./forge.config.cjs" + } +} diff --git a/packages/lina-desktop/scripts/build.ts b/packages/lina-desktop/scripts/build.ts new file mode 100644 index 0000000..34d6a38 --- /dev/null +++ b/packages/lina-desktop/scripts/build.ts @@ -0,0 +1,27 @@ +import { mkdir, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { loadWebAssets } from "../../lina-web/src/assets.ts"; +import { writeBundle } from "./bundle-assets.ts"; + +const root = new URL("../", import.meta.url); +const out = fileURLToPath(new URL("dist/", root)); +const assets = await loadWebAssets(); +// Only generated package output is replaced; userData and sessions are elsewhere. +await rm(out, { recursive: true, force: true }); +await mkdir(out, { recursive: true }); +await writeBundle(fileURLToPath(new URL("dist/assets/", root)), assets); +for (const entry of ["main", "preload"]) { + const result = await Bun.build({ + entrypoints: [fileURLToPath(new URL(`src/${entry}.ts`, root))], + outdir: out, + naming: `${entry}.cjs`, + target: "node", + format: "cjs", + external: ["electron"], + }); + if (!result.success) + throw new AggregateError(result.logs, `Desktop ${entry} build failed`); +} +console.log( + `Desktop main, preload and bundled shared renderer built in ${out}`, +); diff --git a/packages/lina-desktop/scripts/bundle-assets.ts b/packages/lina-desktop/scripts/bundle-assets.ts new file mode 100644 index 0000000..d8492b2 --- /dev/null +++ b/packages/lina-desktop/scripts/bundle-assets.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { WebAssets } from "../../lina-web/src/server.ts"; + +/** Shared browser output is the sole renderer source; only PWA metadata is omitted. */ +export async function writeBundle( + directory: string, + assets: WebAssets, +): Promise { + await mkdir(directory, { recursive: true }); + const html = assets.html.replace(/]*\brel="manifest"[^>]*>/gi, ""); + const files = new Map([ + ["/", { body: html, mime: "text/html; charset=utf-8" }], + [ + "/app.js", + { body: assets.script, mime: "text/javascript; charset=utf-8" }, + ], + ["/styles.css", { body: assets.css, mime: "text/css; charset=utf-8" }], + [ + "/theme.js", + { + body: assets.themeScript ?? "", + mime: "text/javascript; charset=utf-8", + }, + ], + ["/favicon.svg", { body: assets.icon, mime: "image/svg+xml" }], + ]); + const manifest: Array<{ url: string; file: string; mime: string }> = []; + const all = new Map( + files, + ); + for (const [url, value] of assets.pwa?.files ?? []) { + if (!url.endsWith(".html") && !url.endsWith(".webmanifest")) + all.set(url, value); + } + for (const [url, value] of all) { + const body = + typeof value.body === "string" + ? Buffer.from(value.body) + : Buffer.from(value.body); + const file = `${createHash("sha256").update(body).digest("hex")}.bin`; + await writeFile(join(directory, file), body); + manifest.push({ url, file, mime: value.mime }); + } + await writeFile( + join(directory, "manifest.json"), + `${JSON.stringify({ format: 1, rendererVersion: assets.pwa?.version, files: manifest }, null, 2)}\n`, + ); +} diff --git a/packages/lina-desktop/scripts/qa-node.ts b/packages/lina-desktop/scripts/qa-node.ts new file mode 100644 index 0000000..77c683f --- /dev/null +++ b/packages/lina-desktop/scripts/qa-node.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { connect } from "node:net"; +import { startBroker } from "../src/broker.ts"; +import { createBrokerSession } from "../src/session.ts"; + +// Executed as compiled Node code, independent of Bun's node:http compatibility layer. +const upstream = createServer((req, res) => { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + origin: req.headers.origin, + cookie: req.headers.cookie ?? null, + }), + ); +}); +upstream.listen(0, "127.0.0.1"); +await once(upstream, "listening"); +const address = upstream.address(); +assert(address && typeof address !== "string"); +const serverOrigin = `http://127.0.0.1:${address.port}`; +const session = createBrokerSession(); +const broker = await startBroker({ + port: 0, + serverOrigin, + session, + assets: new Map([["/", { body: Buffer.from("bundled"), mime: "text/html" }]]), +}); +try { + // An unauthenticated peer can reset immediately after writing the upgrade. + // Native Node emits an asynchronous write error while sending the rejection. + for (let attempt = 0; attempt < 16; attempt++) { + await new Promise((resolve, reject) => { + const socket = connect(Number(new URL(broker.origin).port), "127.0.0.1"); + socket.once("error", reject); + socket.once("close", () => resolve()); + socket.once("connect", () => + socket.write( + `GET /ws HTTP/1.1\r\nHost: ${new URL(broker.origin).host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\n\r\n`, + () => socket.resetAndDestroy(), + ), + ); + }); + } + assert.equal((await fetch(broker.origin)).status, 403); + const response = await fetch(`${broker.origin}/api/agents`, { + headers: { + origin: broker.origin, + cookie: `lina_desktop=${session.cookie(broker.origin).value}`, + }, + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + origin: serverOrigin, + cookie: null, + }); + console.log( + "Node desktop broker: TCP reset survival, auth, upstream Host/Origin, secret stripping PASS", + ); +} finally { + await broker.close(); + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(() => resolve())); +} diff --git a/packages/lina-desktop/src/broker.ts b/packages/lina-desktop/src/broker.ts new file mode 100644 index 0000000..e5874e4 --- /dev/null +++ b/packages/lina-desktop/src/broker.ts @@ -0,0 +1,163 @@ +import { createServer, type IncomingMessage } from "node:http"; +import type { Socket } from "node:net"; +import { serverOrigin } from "./config.ts"; +import { proxyHttp } from "./http-proxy.ts"; +import { apiStatus, requestUrl, socketPath } from "./routes.ts"; +import type { BrokerSession } from "./session.ts"; +import { proxySocket, rejectUpgrade } from "./socket-proxy.ts"; + +export type BundledAsset = { readonly body: Buffer; readonly mime: string }; +export type BundledAssets = ReadonlyMap; +const SECURITY = { + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "no-referrer", + "cross-origin-resource-policy": "same-origin", + "content-security-policy": + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; worker-src 'none'", +}; + +function trusted( + request: IncomingMessage, + origin: string, + session: BrokerSession, + active: boolean, +): boolean { + for (const name of ["host", "origin", "cookie"]) { + if ( + request.rawHeaders.filter( + (_, i) => i % 2 === 0 && request.rawHeaders[i]?.toLowerCase() === name, + ).length > 1 + ) + return false; + } + if ( + request.headers.host !== new URL(origin).host || + !session.authorizes(request.headers.cookie) + ) + return false; + const supplied = request.headers.origin; + if (supplied !== undefined) return supplied === origin; + return ( + !active || + (request.method === "GET" && + request.headers["sec-fetch-site"] === "same-origin") + ); +} + +export async function startBroker(options: { + port: number; + serverOrigin: string; + session: BrokerSession; + assets: BundledAssets; +}) { + const targetOrigin = serverOrigin( + `${options.serverOrigin}${new URL(options.serverOrigin).port ? "" : ":80"}`, + ); + let origin = ""; + const sockets = new Set(); + let upgrades = 0; + const server = createServer({ maxHeaderSize: 16_384 }, (req, res) => { + for (const [key, value] of Object.entries(SECURITY)) + res.setHeader(key, value); + const fail = (status: number, message: string) => { + res.writeHead(status); + res.end(message); + req.resume(); + }; + const url = requestUrl(req.url, origin); + if (!url) { + fail(400, "Invalid path"); + return; + } + const api = url.pathname.startsWith("/api/"); + if (!trusted(req, origin, options.session, api)) { + fail(403, "Forbidden"); + return; + } + if (api) { + const status = apiStatus(url, req.method ?? ""); + if (status !== 0) { + fail(status ?? 404, "Unsupported request"); + return; + } + res.setHeader( + "content-security-policy", + "default-src 'none'; frame-ancestors 'none'; sandbox", + ); + void proxyHttp( + req, + res, + new URL(url.pathname + url.search, targetOrigin), + ); + return; + } + if (req.method !== "GET" && req.method !== "HEAD") { + fail(405, "Method not allowed"); + return; + } + const asset = options.assets.get(url.pathname); + if (!asset || (url.search && url.pathname !== "/")) { + fail(404, "Not found"); + return; + } + res.setHeader("content-type", asset.mime); + res.end(req.method === "HEAD" ? undefined : asset.body); + }); + server.headersTimeout = 15_000; + server.requestTimeout = 90_000; + server.keepAliveTimeout = 5_000; + server.maxConnections = 64; + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + server.on("upgrade", (req, socket, head) => { + const url = requestUrl(req.url, origin); + if ( + !trusted(req, origin, options.session, true) || + req.headers.origin !== origin + ) { + rejectUpgrade(socket, 403); + return; + } + if (!url || req.method !== "GET" || !socketPath(url)) { + rejectUpgrade(socket, 400); + return; + } + if (upgrades >= 8) { + rejectUpgrade(socket, 429); + return; + } + upgrades++; + socket.once("close", () => { + upgrades--; + }); + proxySocket( + req, + socket, + head, + new URL(url.pathname + url.search, targetOrigin), + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw Error("Missing desktop broker port"); + origin = `http://127.0.0.1:${address.port}`; + return { + origin, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + for (const socket of sockets) socket.destroy(); + }), + }; +} diff --git a/packages/lina-desktop/src/bundled-assets.ts b/packages/lina-desktop/src/bundled-assets.ts new file mode 100644 index 0000000..e24e2df --- /dev/null +++ b/packages/lina-desktop/src/bundled-assets.ts @@ -0,0 +1,41 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { BundledAsset, BundledAssets } from "./broker.ts"; + +export async function loadBundle(directory: string): Promise { + const manifest: unknown = JSON.parse( + await readFile(join(directory, "manifest.json"), "utf8"), + ); + if ( + !manifest || + typeof manifest !== "object" || + !("format" in manifest) || + manifest.format !== 1 || + !("files" in manifest) || + !Array.isArray(manifest.files) + ) + throw Error("Invalid desktop bundle manifest"); + const assets = new Map(); + for (const item of manifest.files) { + if ( + !item || + typeof item !== "object" || + typeof item.url !== "string" || + !/^\/(?:[a-zA-Z0-9/_.-]+)?$/.test(item.url) || + item.url.includes("..") || + typeof item.file !== "string" || + !/^[a-f0-9]{64}\.bin$/.test(item.file) || + typeof item.mime !== "string" || + /[\r\n]/.test(item.mime) || + assets.has(item.url) + ) + throw Error("Invalid desktop bundle entry"); + const body = await readFile(join(directory, item.file)); + if (`${createHash("sha256").update(body).digest("hex")}.bin` !== item.file) + throw Error("Desktop bundle checksum mismatch"); + assets.set(item.url, { body, mime: item.mime }); + } + if (!assets.has("/")) throw Error("Desktop bundle is missing its page"); + return assets; +} diff --git a/packages/lina-desktop/src/config.ts b/packages/lina-desktop/src/config.ts new file mode 100644 index 0000000..cf9f877 --- /dev/null +++ b/packages/lina-desktop/src/config.ts @@ -0,0 +1,97 @@ +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +export type DesktopConfig = { + readonly serverOrigin: string; + readonly brokerPort: number; + readonly userData: string; + readonly sessionData: string; + readonly externalOrigins: readonly string[]; +}; +const DEFAULT_BROKER_PORT = 43127; + +function port(raw: string): number { + if (!/^[1-9]\d{0,4}$/.test(raw) || Number(raw) > 65535) + throw Error("Desktop port must be an integer from 1 to 65535."); + return Number(raw); +} + +/** Reject DNS and noncanonical IP spellings before URL normalization. */ +export function serverOrigin(raw: string): string { + const match = /^http:\/\/(127\.0\.0\.1|\[::1\]):([1-9]\d{0,4})\/?$/.exec(raw); + if (!match?.[2]) + throw Error( + "LINA_DESKTOP_SERVER_URL must be http://127.0.0.1:PORT or http://[::1]:PORT.", + ); + port(match[2]); + return new URL(raw).origin; +} + +function httpsUrl(raw: unknown): URL { + if (typeof raw !== "string" || raw.length > 4096 || /[\s\\]/u.test(raw)) + throw Error("Invalid external link."); + const url = new URL(raw); + if (url.protocol !== "https:" || url.username || url.password) + throw Error("Only HTTPS links without credentials are allowed."); + return url; +} + +export function externalUrl(raw: unknown, origins: readonly string[]): string { + // Match the raw form: URL normalization must not turn traversal into a thread link. + if ( + typeof raw === "string" && + /^codex:\/\/threads\/[a-zA-Z0-9_-]{1,128}$/.test(raw) + ) + return raw; + const url = httpsUrl(raw); + if (!origins.includes(url.origin)) + throw Error("External site is not allowed."); + return url.href; +} + +export function desktopConfig( + env: Record, +): DesktopConfig { + const upstream = env["LINA_DESKTOP_SERVER_URL"]; + if (!upstream) + throw Error( + "Set LINA_DESKTOP_SERVER_URL to your Lina web server's loopback HTTP origin.", + ); + const origin = serverOrigin(upstream); + const brokerPort = port( + env["LINA_DESKTOP_PORT"] ?? String(DEFAULT_BROKER_PORT), + ); + if (Number(new URL(origin).port || 80) === brokerPort) + throw Error("Lina server and desktop broker must use different ports."); + const userData = + env["LINA_DESKTOP_USER_DATA"] ?? + join(env["LINA_HOME"] ?? join(homedir(), ".lina"), "desktop"); + if (!isAbsolute(userData)) + throw Error("Desktop data directory must be absolute."); + const externalOrigins = ( + env["LINA_DESKTOP_EXTERNAL_ORIGINS"] ?? "https://github.com" + ) + .split(",") + .filter(Boolean) + .map((value) => { + const url = httpsUrl(value); + if ( + url.pathname !== "/" || + url.search || + url.hash || + url.hostname === "localhost" || + /^[\d[]/.test(url.hostname) + ) + throw Error( + "External allowlist entries must be exact public HTTPS origins.", + ); + return url.origin; + }); + return { + serverOrigin: origin, + brokerPort, + userData, + sessionData: join(userData, "sessions"), + externalOrigins, + }; +} diff --git a/packages/lina-desktop/src/http-proxy.ts b/packages/lina-desktop/src/http-proxy.ts new file mode 100644 index 0000000..5800ef9 --- /dev/null +++ b/packages/lina-desktop/src/http-proxy.ts @@ -0,0 +1,98 @@ +import { type IncomingMessage, request, type ServerResponse } from "node:http"; + +const UPLOAD_LIMIT = 2_097_152; +const RESPONSE_LIMIT = UPLOAD_LIMIT + 65_536; +const HEADERS = [ + "content-type", + "x-lina-session", + "x-lina-filename", + "x-lina-revision", +]; + +async function bounded( + stream: IncomingMessage, + limit: number, +): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const value of stream) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + size += chunk.length; + if (size > limit) throw new RangeError("Body too large"); + chunks.push(chunk); + } + return Buffer.concat(chunks, size); +} + +export async function proxyHttp( + incoming: IncomingMessage, + outgoing: ServerResponse, + target: URL, +): Promise { + const upload = + target.pathname === "/api/attachments" || + target.pathname.endsWith("/avatar"); + const limit = upload ? UPLOAD_LIMIT : 65_536; + if (Number(incoming.headers["content-length"] ?? 0) > limit) { + outgoing.writeHead(413); + outgoing.end("Request too large"); + incoming.resume(); + return; + } + const cancel = new AbortController(); + const signal = AbortSignal.any([cancel.signal, AbortSignal.timeout(85_000)]); + const disconnect = () => { + if (!outgoing.writableFinished) cancel.abort(); + }; + outgoing.once("close", disconnect); + const abortRead = () => incoming.destroy(); + signal.addEventListener("abort", abortRead, { once: true }); + try { + const body = await bounded(incoming, limit); + signal.removeEventListener("abort", abortRead); + const headers: Record = { + origin: target.origin, + "sec-fetch-site": "same-origin", + "accept-encoding": "identity", + }; + for (const key of HEADERS) { + const value = incoming.headers[key]; + if (typeof value === "string" && value.length <= 1024) + headers[key] = value; + } + headers["content-length"] = String(body.length); + const response = await new Promise((resolve, reject) => { + const req = request( + target, + { method: incoming.method, headers, signal }, + resolve, + ); + req.on("error", reject); + req.end(body); + }); + const status = response.statusCode ?? 502; + if (status >= 300 && status < 400) { + response.destroy(); + throw Error("Redirect rejected"); + } + const result = await bounded(response, RESPONSE_LIMIT); + for (const key of ["content-type", "content-disposition", "retry-after"]) { + const value = response.headers[key]; + if (typeof value === "string") outgoing.setHeader(key, value); + } + outgoing.writeHead(status); + outgoing.end(result); + } catch (error) { + if (!outgoing.destroyed) { + outgoing.writeHead(error instanceof RangeError ? 413 : 502); + outgoing.end( + error instanceof RangeError + ? "Body too large" + : "Lina unavailable. Retry when the server is connected.", + ); + } + } finally { + signal.removeEventListener("abort", abortRead); + outgoing.removeListener("close", disconnect); + } +} diff --git a/packages/lina-desktop/src/main.ts b/packages/lina-desktop/src/main.ts new file mode 100644 index 0000000..a7b8263 --- /dev/null +++ b/packages/lina-desktop/src/main.ts @@ -0,0 +1,100 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { + app, + type BrowserWindow, + dialog, + Menu, + type MenuItemConstructorOptions, + session, +} from "electron"; +import { startBroker } from "./broker.ts"; +import { loadBundle } from "./bundled-assets.ts"; +import { desktopConfig } from "./config.ts"; +import { bindProfile, createBrokerSession } from "./session.ts"; +import { createWindow, installOsHandlers, prepareSession } from "./window.ts"; + +app.setName("Lina"); +app.enableSandbox(); + +async function start(): Promise { + const config = desktopConfig(process.env); + mkdirSync(config.userData, { recursive: true, mode: 0o700 }); + mkdirSync(config.sessionData, { recursive: true, mode: 0o700 }); + app.setPath("userData", config.userData); + app.setPath("sessionData", config.sessionData); + if (!app.requestSingleInstanceLock()) { + app.quit(); + return; + } + await bindProfile(config); + await app.whenReady(); + const credential = createBrokerSession(); + const broker = await startBroker({ + port: config.brokerPort, + serverOrigin: config.serverOrigin, + session: credential, + assets: await loadBundle(join(__dirname, "assets")), + }); + const desktopSession = session.fromPartition("persist:lina-desktop"); + await prepareSession(desktopSession, broker.origin); + await desktopSession.cookies.set(credential.cookie(broker.origin)); + let window: BrowserWindow | undefined; + const showWindow = () => { + if (!window || window.isDestroyed()) + window = createWindow( + desktopSession, + broker.origin, + config.externalOrigins, + ); + else { + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + } + }; + installOsHandlers(() => window, broker.origin, config.externalOrigins); + const menu: MenuItemConstructorOptions[] = [ + ...(process.platform === "darwin" + ? [{ role: "appMenu" as const }] + : [{ label: "Lina", submenu: [{ role: "quit" as const }] }]), + { role: "editMenu" }, + { + label: "보기", + submenu: [ + { role: "reload" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(menu)); + app.on("second-instance", showWindow); + app.on("activate", showWindow); + app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); + }); + let stopping = false; + // A renderer may veto window close to preserve an upload or unsaved draft. + // Keep its transport alive until Electron confirms every window has closed. + app.on("will-quit", (event) => { + if (stopping) return; + event.preventDefault(); + stopping = true; + desktopSession.flushStorageData(); + void broker.close().finally(() => app.quit()); + }); + showWindow(); +} +void start().catch(async (error: unknown) => { + await app.whenReady(); + dialog.showErrorBox( + "Lina desktop could not start", + error instanceof Error ? error.message : "Desktop startup failed.", + ); + app.exit(1); +}); diff --git a/packages/lina-desktop/src/preload.ts b/packages/lina-desktop/src/preload.ts new file mode 100644 index 0000000..0028938 --- /dev/null +++ b/packages/lina-desktop/src/preload.ts @@ -0,0 +1,13 @@ +import { contextBridge, ipcRenderer } from "electron"; + +// Never expose ipcRenderer, Node primitives, filesystem paths or transport secrets. +contextBridge.exposeInMainWorld( + "linaDesktop", + Object.freeze({ + platform: "desktop", + openExternal: (url: string): Promise => + ipcRenderer.invoke("lina:open-external", url), + notify: (input: { title: string; body: string }): Promise => + ipcRenderer.invoke("lina:notify", input), + }), +); diff --git a/packages/lina-desktop/src/routes.ts b/packages/lina-desktop/src/routes.ts new file mode 100644 index 0000000..5fd7996 --- /dev/null +++ b/packages/lina-desktop/src/routes.ts @@ -0,0 +1,92 @@ +const AGENT = "[a-z][a-z0-9-]{0,47}"; +const UUID = + "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"; +const TASK = "[a-zA-Z0-9_-]{1,128}"; +// Transport allowlist mirrors Lina web management paths. Domain validation stays on Lina. +const ROUTES: ReadonlyArray = [ + [/^\/api\/agents$/, ["GET", "POST"]], + [new RegExp(`^/api/agents/${AGENT}$`), ["GET", "PATCH"]], + [new RegExp(`^/api/agents/${AGENT}/mind$`), ["GET"]], + [new RegExp(`^/api/agents/${AGENT}/conversation$`), ["GET", "PATCH"]], + [ + new RegExp( + `^/api/agents/${AGENT}/(?:avatar|revert|memory|mind/retract|conversation/preferences/reset)$`, + ), + ["POST"], + ], + [/^\/api\/avatars\/[a-f0-9]{64}$/, ["GET"]], + [/^\/api\/models$/, ["GET"]], + [/^\/api\/models\/settings$/, ["PATCH"]], + [/^\/api\/models\/test$/, ["POST"]], + [/^\/api\/hub\/status$/, ["GET"]], + [/^\/api\/hub\/refresh$/, ["POST"]], + [/^\/api\/tasks$/, ["GET", "POST"]], + [new RegExp(`^/api/tasks/${TASK}$`), ["GET"]], + [ + new RegExp(`^/api/tasks/${TASK}/(?:messages|interrupt|owner|approval)$`), + ["POST"], + ], + [/^\/api\/onboarding(?:\/entry)?$/, ["GET"]], + [/^\/api\/onboarding\/user$/, ["PATCH"]], + [/^\/api\/onboarding\/(?:drafts|interview|preview)$/, ["POST"]], + [new RegExp(`^/api/onboarding/drafts/${UUID}$`), ["PATCH"]], + [new RegExp(`^/api/onboarding/drafts/${UUID}/(?:answer|apply)$`), ["POST"]], + [new RegExp(`^/api/onboarding/rooms/${UUID}$`), ["GET"]], + [/^\/api\/agents\/birth$/, ["POST"]], + [new RegExp(`^/api/agents/${AGENT}/intro$`), ["GET", "POST"]], + [ + new RegExp( + `^/api/agents/${AGENT}/intro/(?:turn|finish|choose|mode|restart)$`, + ), + ["POST"], + ], +]; +const FILE = new RegExp(`^/api/attachments/${UUID}(?:/preview|/meta)?$`); + +export function requestUrl( + raw: string | undefined, + origin: string, +): URL | undefined { + if ( + !raw || + raw.length > 8192 || + !raw.startsWith("/") || + raw.startsWith("//") || + /[\\\s#]/u.test(raw) + ) + return; + const path = raw.split("?")[0] ?? ""; + if ( + path.includes("%") || + path.split("/").some((part) => part === "." || part === "..") + ) + return; + const url = new URL(raw, origin); + return url.origin === origin ? url : undefined; +} + +/** undefined = unknown route, otherwise HTTP rejection status or 0 (allowed). */ +export function apiStatus(url: URL, method: string): number | undefined { + if (url.pathname === "/api/attachments") + return url.search ? 400 : method === "POST" ? 0 : 405; + if (FILE.test(url.pathname)) { + if (method !== "GET") return 405; + return url.searchParams.size === 1 && + new RegExp(`^${UUID}$`).test(url.searchParams.get("sessionId") ?? "") + ? 0 + : 400; + } + const matches = ROUTES.filter(([pattern]) => pattern.test(url.pathname)); + if (!matches.length) return; + if (url.search) return 400; + return matches.some(([, methods]) => methods.includes(method)) ? 0 : 405; +} + +export function socketPath(url: URL): boolean { + return ( + url.pathname === "/ws" && + (url.searchParams.size === 0 || + (url.searchParams.size === 1 && + new RegExp(`^${AGENT}$`).test(url.searchParams.get("agent") ?? ""))) + ); +} diff --git a/packages/lina-desktop/src/session.ts b/packages/lina-desktop/src/session.ts new file mode 100644 index 0000000..fdb9212 --- /dev/null +++ b/packages/lina-desktop/src/session.ts @@ -0,0 +1,71 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { DesktopConfig } from "./config.ts"; + +export function createBrokerSession() { + const secret = randomBytes(32).toString("hex"); + return { + cookie(origin: string) { + return { + url: origin, + name: "lina_desktop", + value: secret, + path: "/", + httpOnly: true, + sameSite: "strict" as const, + secure: false, + }; + }, + authorizes(header: string | undefined): boolean { + const values = (header ?? "") + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith("lina_desktop=")); + const value = values[0]?.slice("lina_desktop=".length); + return ( + values.length === 1 && + value !== undefined && + /^[a-f0-9]{64}$/.test(value) && + timingSafeEqual(Buffer.from(value), Buffer.from(secret)) + ); + }, + }; +} +export type BrokerSession = ReturnType; + +/** A stable origin is also the renderer's draft/localStorage identity. */ +export async function bindProfile(config: DesktopConfig): Promise { + await mkdir(config.userData, { recursive: true, mode: 0o700 }); + await mkdir(config.sessionData, { recursive: true, mode: 0o700 }); + const file = join(config.userData, "connection.json"); + const binding = { + serverOrigin: config.serverOrigin, + brokerPort: config.brokerPort, + }; + try { + await writeFile(file, `${JSON.stringify(binding)}\n`, { + flag: "wx", + mode: 0o600, + }); + } catch (error) { + if ( + !(error instanceof Error) || + !("code" in error) || + error.code !== "EEXIST" + ) + throw error; + const saved: unknown = JSON.parse(await readFile(file, "utf8")); + if ( + !saved || + typeof saved !== "object" || + !("serverOrigin" in saved) || + !("brokerPort" in saved) || + saved.serverOrigin !== binding.serverOrigin || + saved.brokerPort !== binding.brokerPort + ) + throw Error( + "Desktop profile belongs to a different server or broker port. Choose a separate LINA_DESKTOP_USER_DATA directory.", + ); + } +} diff --git a/packages/lina-desktop/src/socket-proxy.ts b/packages/lina-desktop/src/socket-proxy.ts new file mode 100644 index 0000000..ff426a4 --- /dev/null +++ b/packages/lina-desktop/src/socket-proxy.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import { type IncomingMessage, request } from "node:http"; +import type { Duplex } from "node:stream"; + +export function rejectUpgrade(socket: Duplex, status: number): void { + // HTTP removes its socket error handler on upgrade. A peer may reset before + // this rejection is written, including on paths that never create a tunnel. + socket.once("error", () => socket.destroy()); + socket.end( + `HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); +} + +/** Node streams enforce backpressure; Lina web remains the wire parser/limit owner. */ +export function proxySocket( + incoming: IncomingMessage, + client: Duplex, + head: Buffer, + target: URL, +): void { + const key = incoming.headers["sec-websocket-key"]; + if ( + incoming.headers.upgrade?.toLowerCase() !== "websocket" || + incoming.headers["sec-websocket-version"] !== "13" || + typeof key !== "string" || + !/^[A-Za-z0-9+/]{22}==$/.test(key) || + incoming.headers["sec-websocket-protocol"] + ) { + rejectUpgrade(client, 400); + return; + } + const expected = createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + const upstream = request(target, { + headers: { + origin: target.origin, + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-version": "13", + "sec-websocket-key": key, + }, + }); + let peer: Duplex | undefined; + const deadline = setTimeout( + () => upstream.destroy(Error("Handshake timeout")), + 10_000, + ); + const dispose = () => { + clearTimeout(deadline); + peer?.destroy(); + upstream.destroy(); + }; + client.once("close", dispose); + client.once("error", dispose); + upstream.once("error", () => { + clearTimeout(deadline); + if (!peer && !client.destroyed) rejectUpgrade(client, 502); + else client.destroy(); + }); + upstream.once("response", (response) => { + response.destroy(); + clearTimeout(deadline); + rejectUpgrade(client, 502); + }); + upstream.once("upgrade", (response, socket, initial) => { + clearTimeout(deadline); + if ( + client.destroyed || + response.statusCode !== 101 || + response.headers.upgrade?.toLowerCase() !== "websocket" || + response.headers["sec-websocket-accept"] !== expected || + response.headers["sec-websocket-extensions"] || + response.headers["sec-websocket-protocol"] + ) { + socket.destroy(); + rejectUpgrade(client, 502); + return; + } + peer = socket; + socket.once("error", () => client.destroy()); + socket.once("close", () => client.destroy()); + client.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${expected}\r\n\r\n`, + ); + if (initial.length) client.write(initial); + if (head.length) socket.write(head); + client.pipe(socket); + socket.pipe(client); + }); + upstream.end(); +} diff --git a/packages/lina-desktop/src/window-policy.ts b/packages/lina-desktop/src/window-policy.ts new file mode 100644 index 0000000..293f7d2 --- /dev/null +++ b/packages/lina-desktop/src/window-policy.ts @@ -0,0 +1,57 @@ +import { apiStatus, socketPath } from "./routes.ts"; + +function localUrl(raw: string, origin: string): URL | undefined { + try { + const url = new URL(raw); + return url.origin === origin && !url.username && !url.password + ? url + : undefined; + } catch { + return; + } +} +export function windowUrl(raw: string, origin: string): boolean { + return localUrl(raw, origin)?.pathname === "/"; +} +export function rendererRequest(raw: string, origin: string): boolean { + if (localUrl(raw, origin)) return true; + try { + const url = new URL(raw); + return ( + url.protocol === "ws:" && + !url.username && + !url.password && + url.host === new URL(origin).host && + socketPath(url) + ); + } catch { + return false; + } +} +export function downloadUrl(raw: string, origin: string): boolean { + const url = localUrl(raw, origin); + return ( + !!url && + url.pathname.startsWith("/api/attachments/") && + apiStatus(url, "GET") === 0 + ); +} +export function notificationInput(raw: unknown): { + title: string; + body: string; +} { + if ( + !raw || + typeof raw !== "object" || + Object.keys(raw).some((key) => key !== "title" && key !== "body") || + !("title" in raw) || + !("body" in raw) || + typeof raw.title !== "string" || + typeof raw.body !== "string" || + !raw.title.trim() || + raw.title.length > 100 || + raw.body.length > 500 + ) + throw Error("Invalid notification"); + return { title: raw.title, body: raw.body }; +} diff --git a/packages/lina-desktop/src/window.ts b/packages/lina-desktop/src/window.ts new file mode 100644 index 0000000..ac1ae73 --- /dev/null +++ b/packages/lina-desktop/src/window.ts @@ -0,0 +1,160 @@ +import { basename, join } from "node:path"; +import { + BrowserWindow, + dialog, + type IpcMainInvokeEvent, + ipcMain, + Notification, + type Session, + shell, +} from "electron"; +import { externalUrl } from "./config.ts"; +import { + downloadUrl, + notificationInput, + rendererRequest, + windowUrl, +} from "./window-policy.ts"; + +async function openOsLink( + window: BrowserWindow, + raw: unknown, + origins: readonly string[], +): Promise { + try { + await shell.openExternal(externalUrl(raw, origins)); + } catch { + if (!window.isDestroyed()) + await dialog.showMessageBox(window, { + type: "error", + message: "연결된 앱에서 링크를 열지 못했습니다.", + detail: + typeof raw === "string" && raw.startsWith("codex:") + ? "Codex 앱이 설치되어 있고 링크를 열도록 연결되어 있는지 확인해주세요." + : "허용된 HTTPS 사이트와 기본 브라우저 설정을 확인해주세요.", + }); + throw Error("External link could not be opened."); + } +} + +export async function prepareSession( + session: Session, + origin: string, +): Promise { + await session.setProxy({ mode: "direct" }); + session.setPermissionRequestHandler((_contents, _permission, callback) => + callback(false), + ); + session.setPermissionCheckHandler(() => false); + session.on("will-download", (event, item, contents) => { + if ( + !contents || + !windowUrl(contents.getURL(), origin) || + !item.getURLChain().every((url) => downloadUrl(url, origin)) + ) { + event.preventDefault(); + return; + } + item.setSaveDialogOptions({ + title: "첨부 파일 저장", + defaultPath: basename(item.getFilename()), + }); + }); + // Cookies are not port-scoped. Deny every other origin before any network request. + session.webRequest.onBeforeRequest((details, callback) => + callback({ cancel: !rendererRequest(details.url, origin) }), + ); +} + +export function createWindow( + session: Session, + origin: string, + externalOrigins: readonly string[], +): BrowserWindow { + const window = new BrowserWindow({ + title: "Lina", + width: 1280, + height: 860, + minWidth: 680, + minHeight: 520, + backgroundColor: "#181818", + show: false, + webPreferences: { + session, + preload: join(__dirname, "preload.cjs"), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + nodeIntegrationInWorker: false, + nodeIntegrationInSubFrames: false, + webSecurity: true, + allowRunningInsecureContent: false, + webviewTag: false, + }, + }); + const open = (raw: string) => { + void openOsLink(window, raw, externalOrigins).catch(() => { + // The native error dialog already reported this navigation failure. + }); + }; + window.webContents.setWindowOpenHandler(({ url }) => { + void open(url); + return { action: "deny" }; + }); + window.webContents.on("will-navigate", (event, url) => { + if (!windowUrl(url, origin)) { + event.preventDefault(); + void open(url); + } + }); + window.webContents.on("will-redirect", (event) => event.preventDefault()); + window.webContents.on("will-attach-webview", (event) => + event.preventDefault(), + ); + window.webContents.on("render-process-gone", () => { + void dialog.showMessageBox(window, { + type: "error", + message: "화면이 종료되었습니다.", + detail: "Lina 서버의 작업은 계속됩니다. 앱을 다시 열어주세요.", + }); + }); + window.once("ready-to-show", () => window.show()); + void window.loadURL(origin).catch(() => { + dialog.showErrorBox( + "Lina", + "동봉 화면을 열지 못했습니다. 앱을 다시 실행해주세요.", + ); + }); + return window; +} + +export function installOsHandlers( + current: () => BrowserWindow | undefined, + origin: string, + externalOrigins: readonly string[], +): void { + const authorize = (event: IpcMainInvokeEvent) => { + const window = current(); + if ( + !window || + window.isDestroyed() || + event.sender !== window.webContents || + event.senderFrame !== window.webContents.mainFrame || + !event.senderFrame || + !windowUrl(event.senderFrame.url, origin) + ) + throw Error("Unauthorized desktop request"); + return window; + }; + ipcMain.handle("lina:open-external", async (event, raw: unknown) => { + const window = authorize(event); + await openOsLink(window, raw, externalOrigins); + }); + ipcMain.handle("lina:notify", (event, raw: unknown) => { + authorize(event); + const input = notificationInput(raw); + if (!Notification.isSupported()) return false; + new Notification(input).show(); + return true; + }); +} diff --git a/packages/lina-desktop/test/assets.test.ts b/packages/lina-desktop/test/assets.test.ts new file mode 100644 index 0000000..0628836 --- /dev/null +++ b/packages/lina-desktop/test/assets.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeBundle } from "../scripts/bundle-assets.ts"; +import { loadBundle } from "../src/bundled-assets.ts"; + +test("disk bundle survives without Bun/web source and preserves versioned asset URLs", async () => { + const qa = fileURLToPath(new URL("../.qa/", import.meta.url)); + await mkdir(qa, { recursive: true }); + const dir = await mkdtemp(join(qa, "bundle-")); + await writeBundle(dir, { + html: '', + script: "console.log('shared renderer')", + css: "body{}", + icon: "", + pwa: { + version: "rev", + worker: "worker", + manifest: "{}", + files: new Map([ + [ + "/assets/rev/app.js", + { body: "shared-code", mime: "text/javascript" }, + ], + ]), + }, + }); + const bundle = await loadBundle(dir); + expect(bundle.get("/")?.body.toString()).not.toContain('rel="manifest"'); + expect(bundle.get("/assets/rev/app.js")?.body.toString()).toBe("shared-code"); + expect(bundle.has("/sw.js")).toBe(false); + await writeFile( + join(dir, "manifest.json"), + JSON.stringify({ + format: 1, + files: [{ url: "/", file: "../../package.json", mime: "text/html" }], + }), + ); + await expect(loadBundle(dir)).rejects.toThrow(); +}); diff --git a/packages/lina-desktop/test/broker.test.ts b/packages/lina-desktop/test/broker.test.ts new file mode 100644 index 0000000..0a79c14 --- /dev/null +++ b/packages/lina-desktop/test/broker.test.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash, randomBytes } from "node:crypto"; +import { once } from "node:events"; +import { createServer, type IncomingHttpHeaders, request } from "node:http"; +import { createAccessPolicy } from "../../lina-web/src/access.ts"; +import { startBroker } from "../src/broker.ts"; +import { createBrokerSession } from "../src/session.ts"; + +const assets = new Map([ + ["/", { body: Buffer.from("bundled Lina"), mime: "text/html" }], +]); +const uuid = "01234567-89ab-4cde-8fab-0123456789ab"; +const pending: Array<() => Promise> = []; +afterEach(async () => { + for (const close of pending.splice(0).reverse()) await close(); +}); + +async function fixture() { + const seen: Array<{ + path: string; + headers: IncomingHttpHeaders; + body: string; + }> = []; + const upstream = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk.toString(); + seen.push({ path: req.url ?? "", headers: req.headers, body }); + const access = createAccessPolicy(); + if ( + !access.allowsSocket( + req.headers.host ?? null, + String(req.headers.origin ?? ""), + ) + ) { + res.writeHead(403); + res.end(); + return; + } + if (req.url === "/api/models") { + res.writeHead(302, { location: "http://127.0.0.1:1/private" }); + res.end(); + return; + } + res.writeHead(200, { + "content-type": "application/json", + "set-cookie": "upstream=leaked", + "content-disposition": "attachment; filename=test.txt", + }); + res.end(JSON.stringify({ path: req.url, body })); + }); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const addr = upstream.address(); + if (!addr || typeof addr === "string") throw Error("No upstream port"); + const origin = `http://127.0.0.1:${addr.port}`; + const session = createBrokerSession(); + const broker = await startBroker({ + port: 0, + serverOrigin: origin, + assets, + session, + }); + pending.push(async () => { + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(() => resolve())); + }); + pending.push(broker.close); + const cookie = `lina_desktop=${session.cookie(broker.origin).value}`; + const auth = { cookie, origin: broker.origin }; + return { upstream, origin, seen, broker, cookie, auth }; +} + +function raw( + origin: string, + path: string, + headers: Record, + method = "GET", + body?: string, +) { + return new Promise<{ + status: number; + headers: IncomingHttpHeaders; + body: string; + }>((resolve, reject) => { + const req = request(origin, { path, method, headers }, (res) => { + let text = ""; + res.on("data", (chunk) => { + text += chunk.toString(); + }); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: text, + }), + ); + res.on("error", reject); + }); + req.on("error", reject); + req.end(body); + }); +} + +describe("main-owned loopback broker", () => { + let f: Awaited>; + beforeEach(async () => { + f = await fixture(); + }); + test("never issues credentials from root; bundled UI loads without upstream", async () => { + const anonymous = await raw(f.broker.origin, "/", {}); + expect(anonymous.status).toBe(403); + expect(anonymous.headers["set-cookie"]).toBeUndefined(); + const page = await raw(f.broker.origin, "/?agent=lina", { + cookie: f.cookie, + }); + expect(page.status).toBe(200); + expect(page.body).toContain("bundled Lina"); + expect(page.body).not.toContain(f.cookie); + expect(page.headers["content-security-policy"]).toContain( + "worker-src 'none'", + ); + expect(f.seen.length).toBe(0); + }); + test("rejects missing session, forged Host, hostile/missing Origin and cross-port same-site", async () => { + for (const headers of [ + { origin: f.broker.origin }, + { ...f.auth, host: "evil.test" }, + { ...f.auth, origin: "http://evil.test" }, + { ...f.auth, origin: "null" }, + { ...f.auth, origin: "http://127.0.0.1:9999" }, + { cookie: f.cookie }, + { cookie: f.cookie, "sec-fetch-site": "same-site" }, + ]) { + expect((await raw(f.broker.origin, "/api/agents", headers)).status).toBe( + 403, + ); + } + expect( + ( + await raw(f.broker.origin, "/", { + ...f.auth, + origin: "https://evil.test", + }) + ).status, + ).toBe(403); + expect(f.seen.length).toBe(0); + }); + test("forwards exact contracts and metadata with pinned server Host/Origin and strips cookies", async () => { + const upload = await raw( + f.broker.origin, + "/api/attachments", + { + ...f.auth, + "x-lina-session": uuid, + "x-lina-filename": "note.txt", + "content-type": "application/octet-stream", + authorization: "do-not-forward", + "x-forwarded-host": "evil.test", + }, + "POST", + "attachment bytes", + ); + expect(upload.status).toBe(200); + expect(upload.headers["set-cookie"]).toBeUndefined(); + expect(upload.headers["content-disposition"]).toBe( + "attachment; filename=test.txt", + ); + expect(f.seen[0]?.headers).toMatchObject({ + host: new URL(f.origin).host, + origin: f.origin, + "x-lina-session": uuid, + "x-lina-filename": "note.txt", + }); + expect(f.seen[0]?.headers.cookie).toBeUndefined(); + expect(f.seen[0]?.headers.authorization).toBeUndefined(); + expect(f.seen[0]?.headers["x-forwarded-host"]).toBeUndefined(); + expect(f.seen[0]?.body).toBe("attachment bytes"); + const file = await raw( + f.broker.origin, + `/api/attachments/${uuid}/preview?sessionId=${uuid}`, + { cookie: f.cookie, "sec-fetch-site": "same-origin" }, + ); + expect(file.status).toBe(200); + for (const [path, method] of [ + ["/api/tasks", "POST"], + ["/api/tasks/native_1/approval", "POST"], + ["/api/onboarding/user", "PATCH"], + [`/api/onboarding/drafts/${uuid}`, "PATCH"], + ["/api/agents/lina/memory", "POST"], + ["/api/onboarding/entry", "GET"], + [`/api/onboarding/rooms/${uuid}`, "GET"], + ["/api/agents/lina/intro/turn", "POST"], + ["/api/agents/lina/conversation", "PATCH"], + ] as const) { + expect( + ( + await raw( + f.broker.origin, + path, + f.auth, + method, + method === "GET" ? undefined : "{}", + ) + ).status, + ).toBe(200); + } + }); + test("rejects unknown endpoints, traversal, query injection, unsupported methods and redirects", async () => { + for (const path of [ + "/api/private", + "/api/agents/../models", + "/api/%61gents", + "/api/agents?target=http://evil.test", + "//evil.test/api/agents", + "http://evil.test/api/agents", + `/api/attachments/${uuid}?sessionId=${uuid}&sessionId=${uuid}`, + "/sw.js", + ]) { + expect( + (await raw(f.broker.origin, path, f.auth)).status, + ).toBeGreaterThanOrEqual(400); + } + expect( + (await raw(f.broker.origin, "/api/agents", f.auth, "DELETE")).status, + ).toBe(405); + expect( + (await raw(f.broker.origin, "/api/models", f.auth, "POST", "{}")).status, + ).toBe(405); + expect(f.seen.length).toBe(0); + const redirect = await raw(f.broker.origin, "/api/models", f.auth); + expect(redirect.status).toBe(502); + expect(redirect.headers.location).toBeUndefined(); + expect(f.seen.length).toBe(1); + }); + test("bounds uploads before reaching upstream", async () => { + expect( + ( + await raw( + f.broker.origin, + "/api/attachments", + { ...f.auth, "content-length": "2097153" }, + "POST", + ) + ).status, + ).toBe(413); + expect(f.seen.length).toBe(0); + }); + test("returns recoverable failure offline, keeps assets available and resumes on next request", async () => { + await new Promise((resolve) => f.upstream.close(() => resolve())); + expect((await raw(f.broker.origin, "/api/agents", f.auth)).status).toBe( + 502, + ); + expect((await raw(f.broker.origin, "/", { cookie: f.cookie })).status).toBe( + 200, + ); + f.upstream.listen(Number(new URL(f.origin).port), "127.0.0.1"); + await once(f.upstream, "listening"); + expect((await raw(f.broker.origin, "/api/agents", f.auth)).status).toBe( + 200, + ); + }); + test("WS rejects non-upgrade responses and a forged upstream handshake", async () => { + const headers = { + ...f.auth, + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-version": "13", + "sec-websocket-key": randomBytes(16).toString("base64"), + }; + expect((await raw(f.broker.origin, "/ws", headers)).status).toBe(502); + f.upstream.once("upgrade", (_req, socket) => { + socket.end( + "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: forged\r\n\r\n", + ); + }); + expect((await raw(f.broker.origin, "/ws", headers)).status).toBe(502); + }); + + test("busy stable port fails without allocating a different renderer origin", async () => { + await expect( + startBroker({ + port: Number(new URL(f.broker.origin).port), + serverOrigin: f.origin, + assets, + session: createBrokerSession(), + }), + ).rejects.toThrow(); + expect((await raw(f.broker.origin, "/", { cookie: f.cookie })).status).toBe( + 200, + ); + }); + + test("WS requires cookie + exact Origin, validates path, and pins upstream handshake", async () => { + const headers = { + ...f.auth, + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-version": "13", + "sec-websocket-key": randomBytes(16).toString("base64"), + }; + for (const [path, override] of [ + ["/ws", { origin: "https://evil.test" }], + ["/ws", { cookie: "" }], + ["/ws?agent=lina&target=evil", {}], + ["/api/agents", {}], + ] as const) { + expect( + (await raw(f.broker.origin, path, { ...headers, ...override })).status, + ).toBeGreaterThanOrEqual(400); + } + const handshake = new Promise((resolve) => + f.upstream.once("upgrade", (req, socket) => { + resolve(req.headers); + const accept = createHash("sha1") + .update( + `${req.headers["sec-websocket-key"]}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`, + ) + .digest("base64"); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket.on("data", (data) => socket.write(data)); + socket.on("end", () => socket.end()); + }), + ); + await new Promise((resolve, reject) => { + const req = request(f.broker.origin, { path: "/ws?agent=lina", headers }); + req.on("error", reject); + req.on("upgrade", (_res, socket) => { + socket.once("data", (data) => { + expect(data.toString()).toBe("tunnel bytes"); + socket.destroy(); + resolve(); + }); + socket.write("tunnel bytes"); + }); + req.end(); + }); + const forwarded = await handshake; + expect(forwarded.origin).toBe(f.origin); + expect(forwarded.host).toBe(new URL(f.origin).host); + expect(forwarded.cookie).toBeUndefined(); + }); +}); diff --git a/packages/lina-desktop/test/config.test.ts b/packages/lina-desktop/test/config.test.ts new file mode 100644 index 0000000..171954f --- /dev/null +++ b/packages/lina-desktop/test/config.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { desktopConfig, externalUrl, serverOrigin } from "../src/config.ts"; + +describe("desktop configuration boundary", () => { + test("requires an explicit server and literal loopback pin", () => { + expect(() => desktopConfig({})).toThrow(); + for (const url of [ + "https://127.0.0.1:3000", + "http://localhost:3000", + "http://127.1:3000", + "http://2130706433:3000", + "http://127.0.0.1.evil:3000", + "http://127.0.0.1:3000/api", + "http://127.0.0.1:3000/?url=evil", + "http://x@127.0.0.1:3000", + "http://127.0.0.1:0", + "http://127.0.0.1:3000/#x", + ]) { + expect(() => serverOrigin(url)).toThrow(); + } + expect(serverOrigin("http://127.0.0.1:3000/")).toBe( + "http://127.0.0.1:3000", + ); + expect(serverOrigin("http://[::1]:3000")).toBe("http://[::1]:3000"); + }); + test("keeps broker origin and storage stable; supports isolated QA configuration", () => { + const env = { + LINA_DESKTOP_SERVER_URL: "http://127.0.0.1:18140", + LINA_HOME: resolve("packages/lina-desktop/.qa/home"), + }; + const a = desktopConfig(env); + expect(a).toEqual(desktopConfig(env)); + expect(a.brokerPort).toBe(43127); + expect(a.userData).toBe(resolve("packages/lina-desktop/.qa/home/desktop")); + const b = desktopConfig({ + ...env, + LINA_DESKTOP_PORT: "18141", + LINA_DESKTOP_USER_DATA: resolve("packages/lina-desktop/.qa/isolated"), + }); + expect(b.brokerPort).toBe(18141); + expect(b.sessionData).toBe( + resolve("packages/lina-desktop/.qa/isolated/sessions"), + ); + for (const port of ["0", "-1", "65536", "1.2", " 18141", "18140"]) + expect(() => + desktopConfig({ ...env, LINA_DESKTOP_PORT: port }), + ).toThrow(); + }); + test("external links require exact HTTPS allowlisted origins", () => { + const allow = ["https://github.com"]; + expect(externalUrl("https://github.com/electron/electron", allow)).toBe( + "https://github.com/electron/electron", + ); + for (const url of [ + "https://github.com.evil/", + "https://github.com:444/", + "https://user@github.com/", + "file:///etc/passwd", + "javascript:alert(1)", + "https://127.0.0.1/", + "http://github.com/", + "https://github.com/\nfoo", + ]) + expect(() => externalUrl(url, allow)).toThrow(); + }); + test("Codex links allow only a canonical existing thread id", () => { + const good = "codex://threads/019b1234-5678-7000-abcd-0123456789ab"; + expect(externalUrl(good, [])).toBe(good); + expect(externalUrl("codex://threads/native_task-1", [])).toBe( + "codex://threads/native_task-1", + ); + for (const url of [ + "codex://commands/run", + "codex://threads", + "codex://threads/", + "codex://threads/a/../b", + "codex://threads/%2e%2e", + "codex://threads/a%2fb", + "codex://threads/a?x=1", + "codex://threads/a#x", + "codex://user@threads/a", + "codex://threads:80/a", + "codex://threads/a/", + "codex://threads/a\\b", + "codex://threads/..", + "codex://threads/" + "a".repeat(129), + "other://threads/a", + ]) + expect(() => externalUrl(url, [])).toThrow(); + }); +}); diff --git a/packages/lina-desktop/test/lifecycle.test.ts b/packages/lina-desktop/test/lifecycle.test.ts new file mode 100644 index 0000000..7d8b904 --- /dev/null +++ b/packages/lina-desktop/test/lifecycle.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; + +test("a cancelled window close keeps the broker alive; accepted quit closes it once", async () => { + const root = mkdtempSync(join(tmpdir(), "lina-quit-test-")); + const handlers = new Map< + string, + (event: { preventDefault(): void }) => void + >(); + let closed = 0, + flushed = 0, + quit = 0; + const ready = Promise.withResolvers(); + const ended = Promise.withResolvers(); + const app = { + setName() {}, + enableSandbox() {}, + setPath() {}, + requestSingleInstanceLock: () => true, + whenReady: async () => {}, + on: (name: string, handler: (event: { preventDefault(): void }) => void) => + handlers.set(name, handler), + quit() { + quit++; + ended.resolve(); + }, + exit: (code: number) => ready.reject(new Error(`Startup failed: ${code}`)), + }; + const fake = { + app, + Menu: { + buildFromTemplate: (value: unknown) => value, + setApplicationMenu() {}, + }, + dialog: { + showErrorBox: (_title: string, message: string) => + ready.reject(new Error(message)), + }, + session: { + fromPartition: () => ({ + cookies: { set: async () => {} }, + flushStorageData() { + flushed++; + }, + }), + }, + }; + const stubs: Record = { + "broker.ts": + "export async function startBroker(){return {origin:'http://127.0.0.1:43127',close:globalThis.closeBroker}}", + "bundled-assets.ts": "export async function loadBundle(){return new Map()}", + "session.ts": + "export async function bindProfile(){};export function createBrokerSession(){return {cookie(){return {}}}}", + "window.ts": + "export async function prepareSession(){};export function installOsHandlers(){};export function createWindow(){globalThis.windowReady();return {}}", + }; + try { + const built = await Bun.build({ + entrypoints: [new URL("../src/main.ts", import.meta.url).pathname], + target: "node", + format: "cjs", + external: ["electron"], + plugins: [ + { + name: "lifecycle-boundaries", + setup(builder) { + builder.onLoad( + { filter: /\/(broker|bundled-assets|session|window)\.ts$/ }, + ({ path }) => ({ + contents: stubs[path.split("/").at(-1) ?? ""] ?? "", + loader: "js", + }), + ); + }, + }, + ], + }); + expect(built.success).toBe(true); + const require = createRequire(import.meta.url); + runInNewContext((await built.outputs[0]?.text()) ?? "", { + require: (id: string) => (id === "electron" ? fake : require(id)), + module: { exports: {} }, + exports: {}, + __dirname: root, + URL, + process: { + platform: "linux", + env: { + LINA_DESKTOP_SERVER_URL: "http://127.0.0.1:18140", + LINA_DESKTOP_USER_DATA: root, + }, + }, + windowReady: () => ready.resolve(), + closeBroker: async () => { + closed++; + }, + }); + await ready.promise; + // Electron emits before-quit before asking windows to close. A renderer + // may veto there; will-quit is only emitted after all windows have closed. + handlers.get("before-quit")?.({ preventDefault() {} }); + expect(closed).toBe(0); + expect(flushed).toBe(0); + handlers.get("will-quit")?.({ preventDefault() {} }); + await ended.promise; + expect(closed).toBe(1); + expect(flushed).toBe(1); + expect(quit).toBe(1); + handlers.get("will-quit")?.({ preventDefault() {} }); + expect(closed).toBe(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/lina-desktop/test/node-runtime.test.ts b/packages/lina-desktop/test/node-runtime.test.ts new file mode 100644 index 0000000..3373e43 --- /dev/null +++ b/packages/lina-desktop/test/node-runtime.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +test("native Node broker survives rejected upgrade TCP resets", async () => { + const root = new URL("../", import.meta.url); + const outdir = fileURLToPath(new URL(".qa/node/", root)); + await mkdir(outdir, { recursive: true }); + const build = await Bun.build({ + entrypoints: [fileURLToPath(new URL("scripts/qa-node.ts", root))], + target: "node", + format: "esm", + outdir, + naming: "broker.mjs", + }); + expect(build.success).toBe(true); + const process = Bun.spawn(["node", `${outdir}/broker.mjs`], { + stdout: "pipe", + stderr: "pipe", + }); + const [status, output, errors] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), + ]); + expect(errors).toBe(""); + expect(status).toBe(0); + expect(output).toContain("PASS"); +}); diff --git a/packages/lina-desktop/test/preload.test.ts b/packages/lina-desktop/test/preload.test.ts new file mode 100644 index 0000000..fbb47ab --- /dev/null +++ b/packages/lina-desktop/test/preload.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; + +test("compiled sandbox preload exposes only marker and named OS capabilities", async () => { + const output = await Bun.build({ + entrypoints: [fileURLToPath(new URL("../src/preload.ts", import.meta.url))], + target: "node", + format: "cjs", + external: ["electron"], + }); + expect(output.success).toBe(true); + let exposed: Record = {}; + const calls: unknown[][] = []; + runInNewContext((await output.outputs[0]?.text()) ?? "", { + require(name: string) { + expect(name).toBe("electron"); + return { + contextBridge: { + exposeInMainWorld(name: string, value: Record) { + expect(name).toBe("linaDesktop"); + exposed = value; + }, + }, + ipcRenderer: { + invoke(...args: unknown[]) { + calls.push(args); + return Promise.resolve(); + }, + }, + }; + }, + }); + expect(Object.keys(exposed).sort()).toEqual([ + "notify", + "openExternal", + "platform", + ]); + expect(exposed["platform"]).toBe("desktop"); + expect(Object.isFrozen(exposed)).toBe(true); + // VM exports are untyped; assert the public functions before invoking them. + const open = exposed["openExternal"], + notify = exposed["notify"]; + if (typeof open !== "function" || typeof notify !== "function") + throw Error("Missing OS methods"); + await open("https://github.com/"); + await notify({ title: "Lina", body: "Done" }); + expect(calls).toEqual([ + ["lina:open-external", "https://github.com/"], + ["lina:notify", { title: "Lina", body: "Done" }], + ]); +}); + +test("main IPC rejects another window and same-origin child frames before OS access", async () => { + const output = await Bun.build({ + entrypoints: [fileURLToPath(new URL("../src/window.ts", import.meta.url))], + target: "node", + format: "cjs", + external: ["electron"], + }); + expect(output.success).toBe(true); + const handlers = new Map< + string, + (event: unknown, input: unknown) => Promise + >(); + const opened: string[] = []; + let failOpen = false; + const dialogs: unknown[] = []; + const exports: Record = {}; + const module = { exports }; + runInNewContext((await output.outputs[0]?.text()) ?? "", { + module, + exports, + URL, + require(name: string) { + if (name === "node:path") + return { + basename: (s: string) => s, + join: (...s: string[]) => s.join("/"), + }; + if (name !== "electron") throw Error(`Unexpected runtime import ${name}`); + return { + ipcMain: { + handle: ( + name: string, + action: (event: unknown, input: unknown) => Promise, + ) => handlers.set(name, action), + }, + dialog: { + showMessageBox: async (_window: unknown, options: unknown) => { + dialogs.push(options); + }, + }, + shell: { + openExternal: async (url: string) => { + if (failOpen) throw Error("No OS handler"); + opened.push(url); + }, + }, + }; + }, + }); + const install = module.exports["installOsHandlers"]; + if (typeof install !== "function") throw Error("Missing IPC registration"); + const mainFrame = { url: "http://127.0.0.1:18141/" }; + const contents = { mainFrame }; + install( + () => ({ isDestroyed: () => false, webContents: contents }), + "http://127.0.0.1:18141", + ["https://github.com"], + ); + const open = handlers.get("lina:open-external"); + if (!open) throw Error("Missing handler"); + for (const event of [ + { sender: {}, senderFrame: mainFrame }, + { sender: contents, senderFrame: { ...mainFrame } }, + { sender: contents, senderFrame: null }, + ]) + await expect(open(event, "https://github.com/")).rejects.toThrow(); + await expect( + open({ sender: contents, senderFrame: mainFrame }, "file:///etc/passwd"), + ).rejects.toThrow(); + expect(opened).toEqual([]); + await open( + { sender: contents, senderFrame: mainFrame }, + "https://github.com/electron", + ); + expect(opened).toEqual(["https://github.com/electron"]); + await open( + { sender: contents, senderFrame: mainFrame }, + "codex://threads/native_1", + ); + expect(opened).toEqual([ + "https://github.com/electron", + "codex://threads/native_1", + ]); + failOpen = true; + await expect( + open( + { sender: contents, senderFrame: mainFrame }, + "codex://threads/native_1", + ), + ).rejects.toThrow(); + expect(dialogs.at(-1)).toMatchObject({ + type: "error", + message: "연결된 앱에서 링크를 열지 못했습니다.", + }); +}); diff --git a/packages/lina-desktop/test/session.test.ts b/packages/lina-desktop/test/session.test.ts new file mode 100644 index 0000000..fb1729e --- /dev/null +++ b/packages/lina-desktop/test/session.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { desktopConfig } from "../src/config.ts"; +import { bindProfile, createBrokerSession } from "../src/session.ts"; + +test("session secret stays in main and is installed as a host-only HttpOnly strict cookie", async () => { + const first = createBrokerSession(), + second = createBrokerSession(); + expect(first.authorizes(undefined)).toBe(false); + expect(first.authorizes("lina_desktop=incorrect")).toBe(false); + const cookie = first.cookie("http://127.0.0.1:18141"); + expect(cookie).toMatchObject({ + url: "http://127.0.0.1:18141", + name: "lina_desktop", + httpOnly: true, + sameSite: "strict", + path: "/", + }); + expect(cookie).not.toHaveProperty("domain"); + expect(cookie).not.toHaveProperty("expirationDate"); + expect(cookie.value.length).toBe(64); + expect(first.authorizes(`lina_desktop=${cookie.value}`)).toBe(true); + expect( + first.authorizes( + `lina_desktop=${cookie.value}; lina_desktop=${cookie.value}`, + ), + ).toBe(false); + expect(second.authorizes(`lina_desktop=${cookie.value}`)).toBe(false); +}); + +test("profile restart preserves binding and rejects mixing server/port with old drafts", async () => { + const qa = fileURLToPath(new URL("../.qa/", import.meta.url)); + await mkdir(qa, { recursive: true }); + const dir = await mkdtemp(resolve(qa, "profile-")); + const config = desktopConfig({ + LINA_DESKTOP_SERVER_URL: "http://127.0.0.1:18140", + LINA_DESKTOP_PORT: "18141", + LINA_DESKTOP_USER_DATA: dir, + }); + await bindProfile(config); + const before = await readFile(resolve(dir, "connection.json"), "utf8"); + await bindProfile(config); + expect(await readFile(resolve(dir, "connection.json"), "utf8")).toBe(before); + await expect( + bindProfile({ ...config, serverOrigin: "http://127.0.0.1:19140" }), + ).rejects.toThrow(); + await expect(bindProfile({ ...config, brokerPort: 19141 })).rejects.toThrow(); +}); diff --git a/packages/lina-desktop/test/web-contract.test.ts b/packages/lina-desktop/test/web-contract.test.ts new file mode 100644 index 0000000..8bab753 --- /dev/null +++ b/packages/lina-desktop/test/web-contract.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test"; +import { startWebServer } from "../../lina-web/src/server.ts"; +import { startBroker } from "../src/broker.ts"; +import { createBrokerSession } from "../src/session.ts"; + +test("actual Lina web keeps its origin checks while HTTP and WS cross the desktop bridge", async () => { + const runtime = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + if (new URL(request.url).pathname === "/" && server.upgrade(request)) + return; + return Response.json({ received: new URL(request.url).pathname }); + }, + websocket: { + open(socket) { + socket.send(JSON.stringify({ type: "agent-status", state: "idle" })); + }, + message(socket, raw) { + const frame = JSON.parse(String(raw)); + if (frame.type === "ping") + socket.send( + JSON.stringify({ + type: "pong", + sessionId: frame.sessionId, + nonce: frame.nonce, + }), + ); + }, + }, + }); + const web = startWebServer({ + port: 0, + upstream: `ws://127.0.0.1:${runtime.port}`, + assets: { html: "web-only", script: "", css: "", icon: "" }, + }); + const upstream = `http://127.0.0.1:${web.port}`; + const session = createBrokerSession(); + const broker = await startBroker({ + port: 0, + serverOrigin: upstream, + session, + assets: new Map([ + ["/", { body: Buffer.from("desktop-only"), mime: "text/html" }], + ]), + }); + const headers = { + Origin: broker.origin, + Cookie: `lina_desktop=${session.cookie(broker.origin).value}`, + }; + let socket: WebSocket | undefined; + try { + expect( + ( + await fetch(`${upstream}/api/tasks`, { + headers: { Origin: broker.origin }, + }) + ).status, + ).toBe(403); + const response = await fetch(`${broker.origin}/api/tasks`, { headers }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ received: "/api/tasks" }); + expect(await (await fetch(broker.origin, { headers })).text()).toBe( + "desktop-only", + ); + socket = new WebSocket(`${broker.origin.replace("http:", "ws:")}/ws`, { + headers, + }); + const active = socket; + await new Promise((resolve, reject) => { + active.addEventListener("error", () => + reject(Error("Desktop WS failed")), + ); + active.addEventListener("message", (event) => { + const frame = JSON.parse(String(event.data)); + if (frame.type === "agent-status") { + expect(frame.state).toBe("idle"); + active.send( + JSON.stringify({ + type: "ping", + sessionId: "scope", + nonce: "probe", + }), + ); + } else { + expect(frame).toEqual({ + type: "pong", + sessionId: "scope", + nonce: "probe", + }); + resolve(); + } + }); + }); + } finally { + socket?.close(); + await broker.close(); + await web.stop(true); + await runtime.stop(true); + } +}); diff --git a/packages/lina-desktop/test/window-policy.test.ts b/packages/lina-desktop/test/window-policy.test.ts new file mode 100644 index 0000000..6e84465 --- /dev/null +++ b/packages/lina-desktop/test/window-policy.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { + downloadUrl, + notificationInput, + rendererRequest, + windowUrl, +} from "../src/window-policy.ts"; + +const origin = "http://127.0.0.1:18141"; +test("renderer cannot reach another loopback port or navigate into API content", () => { + expect(windowUrl(`${origin}/?agent=lina`, origin)).toBe(true); + for (const url of [ + "http://127.0.0.1:18140/", + `${origin}/api/agents`, + "file:///etc/passwd", + `${origin}.evil/`, + ]) + expect(windowUrl(url, origin)).toBe(false); + expect(rendererRequest(`${origin}/api/agents`, origin)).toBe(true); + expect(rendererRequest("ws://127.0.0.1:18141/ws?agent=lina", origin)).toBe( + true, + ); + for (const url of [ + "ws://127.0.0.1:18140/ws", + "https://example.com/", + "http://localhost:18141/", + "ws://127.0.0.1:18141/api/agents", + ]) + expect(rendererRequest(url, origin)).toBe(false); +}); +test("downloads remain scoped attachment URLs; notifications have bounded plain text", () => { + const id = "01234567-89ab-4cde-8fab-0123456789ab"; + expect( + downloadUrl(`${origin}/api/attachments/${id}?sessionId=${id}`, origin), + ).toBe(true); + for (const url of [ + `${origin}/api/agents`, + `${origin}/api/attachments/${id}`, + "file:///etc/passwd", + ]) + expect(downloadUrl(url, origin)).toBe(false); + expect(notificationInput({ title: "Lina", body: "작업 완료" })).toEqual({ + title: "Lina", + body: "작업 완료", + }); + for (const value of [ + null, + { title: "", body: "x" }, + { title: "x", body: "x", icon: "/etc/passwd" }, + { title: "x", body: "x".repeat(501) }, + ]) + expect(() => notificationInput(value)).toThrow(); +}); diff --git a/packages/lina-desktop/tsconfig.build.json b/packages/lina-desktop/tsconfig.build.json new file mode 100644 index 0000000..01b3471 --- /dev/null +++ b/packages/lina-desktop/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["scripts", "test"], + "exclude": ["node_modules", "dist", "out"] +} diff --git a/packages/lina-desktop/tsconfig.json b/packages/lina-desktop/tsconfig.json new file mode 100644 index 0000000..89ac852 --- /dev/null +++ b/packages/lina-desktop/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": [] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "out"] +} diff --git a/packages/lina-runtime/src/fleet/agent-summaries.ts b/packages/lina-runtime/src/fleet/agent-summaries.ts new file mode 100644 index 0000000..61a3635 --- /dev/null +++ b/packages/lina-runtime/src/fleet/agent-summaries.ts @@ -0,0 +1,134 @@ +import { lstatSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { isDeepStrictEqual } from "node:util"; +import { + type AgentConversationSummary, + type PublicListMessage, + summaryFromSnapshot, +} from "../../../lina-client/src/agent-list.ts"; +import { + checkedDirectory, + checkedRegular, + readRegular, +} from "../../../lina-core/src/attachments/filesystem.ts"; +import { CONVERSATION_FILTER } from "../../../lina-core/src/conversation.ts"; +import { PREVIEW_MAX_CHARS } from "../../../lina-core/src/protocol.ts"; +import { validateBinding } from "../../../lina-core/src/session-binding.ts"; +import { type AgentFleet, validAgentId } from "./manager.ts"; + +function emptySummary( + agentId: string, + available: boolean, +): AgentConversationSummary { + return { + agentId, + available, + sessionId: null, + revision: 0, + latestMessage: null, + confirmationCount: null, + running: null, + }; +} + +/** Reads one committed public row. Never starts a room, initializes a store or replays an engine. */ +function storedSummary( + root: string, + agentId: string, +): AgentConversationSummary { + const directory = agentId === "lina" ? root : join(root, "agents", agentId); + const path = join(directory, "state.sqlite"); + const manifest = join(directory, "binding.json"); + const exists = (file: string) => + Boolean(lstatSync(file, { throwIfNoEntry: false })); + if (!exists(path) && !exists(manifest)) return emptySummary(agentId, true); + checkedDirectory(directory, false); + checkedRegular(path); + for (const suffix of ["-wal", "-shm", "-journal"]) + checkedRegular(`${path}${suffix}`, false); + const binding = validateBinding( + JSON.parse(new TextDecoder().decode(readRegular(manifest, 65536))), + ); + if (binding.botId !== agentId) throw Error("Foreign summary binding"); + const db = new DatabaseSync(path, { readOnly: true }); + try { + db.exec("BEGIN"); + if (db.prepare("PRAGMA user_version").get()?.["user_version"] !== 1) + throw Error("Unknown summary schema"); + const saved = db + .prepare("SELECT value FROM meta WHERE key = 'binding'") + .get()?.["value"]; + if ( + typeof saved !== "string" || + !isDeepStrictEqual(JSON.parse(saved), binding) + ) + throw Error("Foreign summary store"); + const revision = Number( + db.prepare("SELECT value FROM meta WHERE key = 'revision'").get()?.[ + "value" + ], + ); + if (!Number.isSafeInteger(revision) || revision < 0) + throw Error("Invalid summary revision"); + // This predicate is the public conversation's existing storage projection. + const row = db + .prepare( + `SELECT entry_id, seq, role, substr(text, 1, ?) AS text, timestamp FROM entries WHERE session_id = ? AND (${CONVERSATION_FILTER}) ORDER BY seq DESC LIMIT 1`, + ) + .get(PREVIEW_MAX_CHARS, binding.sessionId); + let latestMessage: PublicListMessage | null = null; + if (row) { + if ( + typeof row["entry_id"] !== "string" || + typeof row["seq"] !== "number" || + (row["role"] !== "user" && row["role"] !== "assistant") || + typeof row["text"] !== "string" || + typeof row["timestamp"] !== "string" + ) + throw Error("Invalid public summary"); + latestMessage = { + entryId: row["entry_id"], + seq: row["seq"], + role: row["role"], + text: row["text"], + timestamp: row["timestamp"], + }; + } + return { + ...emptySummary(agentId, true), + sessionId: binding.sessionId, + revision, + latestMessage, + }; + } finally { + db.close(); + } +} + +export function agentSummaries( + fleet: Pick, +): AgentConversationSummary[] { + return fleet.agents.list().map(({ id }) => { + try { + if (!validAgentId(id)) throw Error("Invalid agent"); + const app = fleet.opened(id); + if (app) { + const snapshot = app.runtime.snapshot(); + const control = app.execution.snapshot(); + const count = + control.sessionId === snapshot.sessionId + ? control.approvals.filter( + (approval) => + approval.state === "pending" && + approval.expiresAt > Date.now(), + ).length + : null; + return summaryFromSnapshot(snapshot, count); + } + return storedSummary(fleet.root, id); + } catch { + return emptySummary(id, false); + } + }); +} diff --git a/packages/lina-runtime/src/fleet/server.ts b/packages/lina-runtime/src/fleet/server.ts index 49be34c..87ae114 100644 --- a/packages/lina-runtime/src/fleet/server.ts +++ b/packages/lina-runtime/src/fleet/server.ts @@ -10,6 +10,7 @@ import { import { inspectContent } from "../../../lina-core/src/attachments/validation.ts"; import { HonchoClient } from "../../../lina-memory/src/honcho/client.ts"; import { defaultConversation } from "../persona/conversation.ts"; +import { agentSummaries } from "./agent-summaries.ts"; import { companionRoutes } from "./companion-routes.ts"; import { introRoutes } from "./intro-routes.ts"; import { type AgentFleet, validAgentId } from "./manager.ts"; @@ -112,7 +113,11 @@ export async function startFleetServer( (primary ?? fleet.opened(primaryId))?.binding.sessionId ?? null, }); if (url.pathname === "/api/agents" && request.method === "GET") - return response({ agents: fleet.summary(), presets: fleet.presets }); + return response({ + agents: fleet.summary(), + summaries: agentSummaries(fleet), + presets: fleet.presets, + }); if (url.pathname === "/api/agents" && request.method === "POST") { const value = await json(request), preset = diff --git a/packages/lina-ui/SOURCES.md b/packages/lina-ui/SOURCES.md new file mode 100644 index 0000000..b9ceda3 --- /dev/null +++ b/packages/lina-ui/SOURCES.md @@ -0,0 +1,25 @@ +# UI component provenance + +Lina adapts shadcn/ui's MIT-licensed `base-nova` component compositions, retrieved +on 2026-09-07, to named exports and the existing semantic CSS tokens. Base UI owns +interactive behavior; no Tailwind framework or shadcn CLI runtime is bundled. + +The registry response has no immutable Git revision field. These SHA-256 digests +pin the exact retrieved snapshots; they must not be presented as an upstream Git +commit. Button, dropdown-menu, tabs and avatar patterns informed the local +wrappers. The input snapshot was inspected but not copied as a separate component. +Combobox uses the installed Base UI 1.8.0 API and its own Lina adapter. + +| Registry snapshot | SHA-256 | +| --- | --- | +| [input.json](https://ui.shadcn.com/r/styles/base-nova/input.json) | `bbad1bba130ac9750a61844eeb8f043e8a710846e07689fa85398b80a46c2741` | +| [button.json](https://ui.shadcn.com/r/styles/base-nova/button.json) | `9ba7e870178813f0552b818a913a2792fb500c779e36395740b4973e3025d427` | +| [tabs.json](https://ui.shadcn.com/r/styles/base-nova/tabs.json) | `b4552a0329dd9a6e2bfaf03405c133874df5a4930c7bb3dd84c9ab5c45b89402` | +| [avatar.json](https://ui.shadcn.com/r/styles/base-nova/avatar.json) | `bbcabf0121fb2ff93a8522f8f80b74631e732a31399b1b7991c0b8c3167e2172` | +| [dropdown-menu.json](https://ui.shadcn.com/r/styles/base-nova/dropdown-menu.json) | `335c59dba30145f434a9cc9ccb0438a3c5e2afe857fc11b029de1ecb415224d7` | + +Runtime versions are pinned in `package.json` and the workspace `bun.lock`. +React 19.2.8, React DOM 19.2.8 and Base UI 1.8.0 are new dependencies of this UI +package. `THIRD_PARTY_LICENSES.txt` preserves their distributed licenses, relevant +transitive dependency licenses and the shadcn MIT notice. `lina-web/src/assets.ts` +includes these notices in the JS served on web and bundled into Electron. diff --git a/packages/lina-ui/THIRD_PARTY_LICENSES.txt b/packages/lina-ui/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..545a720 --- /dev/null +++ b/packages/lina-ui/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,309 @@ +Lina shared renderer: included UI source and runtime dependency licenses. +Versions match bun.lock; update this file when UI dependencies change. + +shadcn/ui base-nova registry (retrieved 2026-09-07) +MIT License + +Copyright (c) 2023 shadcn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +react@19.2.8 +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +react-dom@19.2.8 +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +scheduler@0.27.0 +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +@base-ui/react@1.8.0 +MIT License + +Copyright (c) 2019 Material-UI SAS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +@babel/runtime@7.29.7 +MIT License + +Copyright (c) 2014-present Sebastian McKenzie and other contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +@floating-ui/react-dom@2.1.9 +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +@floating-ui/dom@1.8.0 +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +@floating-ui/core@1.8.0 +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +@floating-ui/utils@0.2.12 +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +use-sync-external-store@1.6.0 +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +@base-ui/utils@0.4.0 +MIT License + +Copyright (c) 2019 Material-UI SAS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +reselect@5.3.0 +The MIT License (MIT) + +Copyright (c) 2015-2018 Reselect Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/lina-web/client/activity.css b/packages/lina-ui/client/activity.css similarity index 100% rename from packages/lina-web/client/activity.css rename to packages/lina-ui/client/activity.css diff --git a/packages/lina-web/client/agent-editor.ts b/packages/lina-ui/client/agent-editor.ts similarity index 100% rename from packages/lina-web/client/agent-editor.ts rename to packages/lina-ui/client/agent-editor.ts diff --git a/packages/lina-ui/client/agent-list.ts b/packages/lina-ui/client/agent-list.ts new file mode 100644 index 0000000..eb5bdcd --- /dev/null +++ b/packages/lina-ui/client/agent-list.ts @@ -0,0 +1,75 @@ +import { createElement } from "react"; +import type { + AgentListModel, + AgentListRow, +} from "../../lina-client/src/agent-list.ts"; +import { + AgentList, + type AgentListActions, + AgentListStatus, +} from "./components/agent-list.tsx"; +import { mountView } from "./components/mount.ts"; + +export function agentAvatar( + profile: Pick, +): HTMLSpanElement { + const avatar = document.createElement("span"); + avatar.className = "avatar"; + avatar.setAttribute("aria-hidden", "true"); + const fallback = () => { + avatar.textContent = Array.from(profile.name.trim())[0] ?? "?"; + }; + fallback(); + if (profile.avatarId) { + const img = document.createElement("img"); + img.src = `/api/avatars/${encodeURIComponent(profile.avatarId)}`; + img.alt = ""; + img.loading = "lazy"; + img.addEventListener("error", fallback, { once: true }); + avatar.replaceChildren(img); + } + return avatar; +} + +export function createAgentListView( + model: AgentListModel, + list: HTMLElement, + status: HTMLElement, + actions: AgentListActions, +) { + const listView = mountView(list); + const statusView = mountView(status); + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + const renderStatus = (copy: string, retry: boolean) => { + statusView.render( + createElement(AgentListStatus, { copy, retry, onRetry: actions.retry }), + ); + status.hidden = !copy; + }; + const render = () => { + const rows = model.rows(); + const filter = document.getElementById("agent-filter"); + const filtered = filter instanceof HTMLInputElement && filter.value.trim(); + const unavailable = rows.some((row) => row.summaryUnavailable); + const copy = + model.status === "error" + ? "에이전트 목록을 불러오지 못했어요. 다시 시도해주세요." + : model.status === "loading" + ? "에이전트를 불러오는 중…" + : !rows.length + ? filtered + ? "이름이나 역할이 일치하는 에이전트가 없어요." + : "아직 에이전트가 없어요. 추가 버튼으로 시작해보세요." + : unavailable + ? "일부 대화 요약을 새로 불러오지 못했어요." + : ""; + listView.render(createElement(AgentList, { rows, actions })); + renderStatus(copy, model.status === "error" || unavailable); + list.setAttribute("aria-busy", String(model.status === "loading")); + }; + return Object.assign(render, { + // Transient navigation errors share React's status root. Rendering the model clears them. + notice: (message: string) => renderStatus(message, false), + }); +} diff --git a/packages/lina-web/client/agent-templates.ts b/packages/lina-ui/client/agent-templates.ts similarity index 100% rename from packages/lina-web/client/agent-templates.ts rename to packages/lina-ui/client/agent-templates.ts diff --git a/packages/lina-web/client/agents.css b/packages/lina-ui/client/agents.css similarity index 64% rename from packages/lina-web/client/agents.css rename to packages/lina-ui/client/agents.css index f6fc70a..1693e17 100644 --- a/packages/lina-web/client/agents.css +++ b/packages/lina-ui/client/agents.css @@ -6,13 +6,10 @@ .agents-heading .section-label { margin: 0; } -.agents-heading .icon-button { - font-size: 24px; -} .avatar img { width: 100%; height: 100%; - border-radius: 10px; + border-radius: var(--control-radius); object-fit: cover; } .avatar:has(img) { @@ -39,39 +36,17 @@ min-width: 32px; min-height: 40px; } -.agent-actions { - position: fixed; - z-index: 20; - min-width: 170px; - padding: 6px; - background: var(--surface-raised); - border: 1px solid var(--line-strong); - border-radius: 9px; -} -.agent-actions button { - width: 100%; - background: none; - border: 0; - color: var(--text); - padding: 12px; - border-radius: 5px; - text-align: left; - font-size: 0.85rem; -} -.agent-actions button:hover { - background: var(--selection); -} .agent-form { - padding: 20px; + padding: 8px 24px 24px; display: flex; flex-direction: column; - gap: 18px; + gap: 16px; } .agent-form label { display: flex; flex-direction: column; gap: 8px; - font-size: 0.85rem; + font-size: 0.875rem; } .agent-form input, .agent-form textarea, @@ -79,18 +54,23 @@ background: var(--input-bg); color: var(--text); border: 1px solid var(--input-border); - border-radius: 7px; - padding: 9px; + border-radius: var(--control-radius); + padding: 7px 11px; width: 100%; + min-width: 0; font: inherit; + line-height: 20px; } .agent-form textarea { min-height: 80px; resize: vertical; max-height: 280px; + padding: 10px 12px; + line-height: 1.6; } +.agent-form input, .agent-form select { - min-height: 40px; + min-height: 36px; } .agent-portrait { display: flex; @@ -101,23 +81,20 @@ width: 88px; height: 88px; object-fit: cover; - border-radius: 20px; + border-radius: var(--menu-radius); } .agent-form summary { cursor: pointer; - font-size: 0.85rem; -} -.agent-form details label { - margin-top: 14px; + font-size: 0.875rem; } .agent-hint { color: var(--muted); - font-size: 0.75rem; + font-size: 0.8125rem; line-height: 1.7; margin-top: 8px; } #agent-growth { - font-size: 0.8rem; + font-size: 0.8125rem; line-height: 1.7; padding-top: 12px; } @@ -125,7 +102,6 @@ margin: 14px 0 4px; } .agent-change { - border-top: 1px solid var(--line); margin-top: 10px; padding-top: 10px; display: flex; @@ -134,7 +110,7 @@ } #current-agent-settings { padding-left: 10px; - font-size: 0.72rem; + font-size: 0.8125rem; } #open-conversation-settings { align-self: flex-start; @@ -144,13 +120,13 @@ max-height: min(760px, calc(100dvh - 24px)); } .conversation-form { - padding: 20px; + padding: 8px 24px 24px; overflow-y: auto; } .conversation-form > label, .conversation-example label { display: block; - font-size: 0.85rem; + font-size: 0.875rem; margin: 14px 0 7px; } .conversation-form textarea { @@ -160,11 +136,24 @@ background: var(--input-bg); color: var(--text); border: 1px solid var(--input-border); - border-radius: 7px; - padding: 9px; + border-radius: var(--control-radius); + padding: 10px 12px; font: inherit; + line-height: 1.6; resize: vertical; } +.agent-form :is(input, textarea, select):focus-visible, +.conversation-form textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.agent-form :is(input, textarea, select):disabled, +.conversation-form textarea:disabled { + opacity: 0.55; +} +.agent-form details label { + margin-top: 16px; +} .conversation-form #conversation-style { max-height: 220px; } @@ -177,32 +166,29 @@ } .conversation-example-heading h3, .conversation-preferences-section h3 { - font-size: 0.9rem; + font-size: 0.875rem; margin: 22px 0 8px; } .conversation-example { - border: 1px solid var(--line); - border-radius: 9px; - padding: 8px 12px 12px; - margin: 10px 0; + border: 0; + padding: 0; + margin: 16px 0; + min-width: 0; } .conversation-example legend { - padding: 0 5px; + padding: 0; color: var(--muted); - font-size: 0.75rem; + font-size: 0.8125rem; } .conversation-example .text-button { margin-top: 8px; } .conversation-preferences-section { - border-top: 1px solid var(--line); margin-top: 24px; - padding-top: 2px; } .conversation-preference { - border-bottom: 1px solid var(--line); padding: 10px 0; - font-size: 0.82rem; + font-size: 0.875rem; } .conversation-preference p, .conversation-empty { @@ -212,15 +198,45 @@ .conversation-actions { display: flex; justify-content: flex-end; - gap: 10px; - margin-top: 22px; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 24px; +} +.agent-form > button[type="submit"] { + align-self: flex-end; +} +.agent-form > button[type="submit"], +.conversation-actions button[type="submit"] { + background: var(--button-bg); + color: var(--button-text); +} +.agent-form > button[type="submit"]:hover:not(:disabled), +.conversation-actions button[type="submit"]:hover:not(:disabled) { + background: var(--button-hover); +} +@media (max-width: 767px) { + .agent-form, + .conversation-form { + padding: 8px 16px 20px; + } + .agent-form input, + .agent-form select { + min-height: 44px; + } + .agent-form input, + .agent-form select, + .agent-form textarea, + .conversation-form textarea { + font-size: 1rem; + } + .agent-actions button { + min-height: 44px; + } } @media (max-width: 480px) { .conversation-dialog { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); } - .conversation-form { - padding: 16px; - } } diff --git a/packages/lina-web/client/agents.ts b/packages/lina-ui/client/agents.ts similarity index 52% rename from packages/lina-web/client/agents.ts rename to packages/lina-ui/client/agents.ts index 1e171a5..e8973cc 100644 --- a/packages/lina-web/client/agents.ts +++ b/packages/lina-ui/client/agents.ts @@ -1,10 +1,17 @@ -import type { - AgentInput, - AgentProfile, - Dynamics, -} from "../../lina-core/src/agents/types.ts"; +import { createElement } from "react"; +import { + type AgentConversationSummary, + AgentListModel, + type AgentListProfile, + summaryFromSnapshot, +} from "../../lina-client/src/agent-list.ts"; +import { browserDraftStore } from "../../lina-client/src/draft.ts"; +import type { SessionSnapshot } from "../../lina-core/src/protocol.ts"; import { agentRequest, createAgentEditor } from "./agent-editor.ts"; +import { createAgentListView } from "./agent-list.ts"; import { createTemplatePicker } from "./agent-templates.ts"; +import { createActionMenu } from "./components/action-menu.tsx"; +import { MindIcon, SettingsIcon } from "./components/icons.tsx"; import { createConversationEditor } from "./conversation-editor.ts"; import { displayName } from "./intro-model.ts"; import { @@ -15,11 +22,20 @@ import { } from "./mind-view.ts"; import { element, setText } from "./render.ts"; -type Summary = AgentProfile & { state: string; dynamics: Dynamics }; -export function createAgents(currentId: string, preserve: () => boolean) { +export function createAgents( + initialId: string, + preserve: () => boolean, + onSelect?: (agentId: string) => void | Promise, +) { + let currentId = initialId; const list = element("other-agents", HTMLDivElement), - primary = element("open-lina", HTMLButtonElement), - menu = element("agent-actions", HTMLDivElement); + primary = document.getElementById("open-lina"); + const status = element("agent-list-status", HTMLElement); + const filter = element("agent-filter", HTMLInputElement); + const model = new AgentListModel(currentId, { + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + }); const mind = createMindView(); const mindDialog = element("mind-dialog", HTMLDialogElement); const mindStatus = element("mind-status", HTMLParagraphElement); @@ -141,29 +157,30 @@ export function createAgents(currentId: string, preserve: () => boolean) { mindRecords.before(retractHint); for (const button of mindDialog.querySelectorAll("[data-close]")) button.addEventListener("click", () => mindDialog.close()); - let agents: Summary[] = [], - signature = "", - selectedId = currentId, - anchor: HTMLElement = primary; - const navigate = (id: string) => { - if (id === currentId) { - element("message", HTMLTextAreaElement).focus(); - return; + let agents: AgentListProfile[] = [], + selectedId = currentId; + let navigationGeneration = 0; + const navigate = async (id: string) => { + if (id !== currentId && !preserve()) return; + const mine = ++navigationGeneration; + try { + if (id !== "lina" && id !== currentId) { + const state = (await agentRequest( + `/api/agents/${encodeURIComponent(id)}/intro`, + )) as { room?: { id: string; status: string } }; + if (mine !== navigationGeneration) return; + if (state.room && state.room.status !== "done") { + location.assign(`/?onboarding=${encodeURIComponent(state.room.id)}`); + return; + } + } + if (onSelect) await onSelect(id); + else if (id !== currentId) + location.assign(`/?agent=${encodeURIComponent(id)}`); + } catch { + if (mine !== navigationGeneration) return; + renderList.notice("대화를 열지 못했어요. 에이전트를 다시 선택해주세요."); } - if (!preserve()) return; - const target = `/?agent=${encodeURIComponent(id)}`; - if (id !== "lina") { - void agentRequest(`/api/agents/${id}/intro`) - .then((value) => { - const state = value as { room?: { id: string; status: string } }; - location.assign( - state.room && state.room.status !== "done" - ? `/?onboarding=${state.room.id}` - : target, - ); - }) - .catch(() => location.assign(target)); - } else location.assign(target); }; const conversationEditor = createConversationEditor(); const editor = createAgentEditor( @@ -178,51 +195,33 @@ export function createAgents(currentId: string, preserve: () => boolean) { ); }, ); - const openMenu = (id: string, element: HTMLElement) => { - selectedId = id; - const drawer = document.getElementById("sidebar-drawer"); - if (drawer instanceof HTMLDialogElement && drawer.open) drawer.close(); - anchor = - window.innerWidth < 768 - ? (document.getElementById("open-sidebar") as HTMLElement) - : element; - const r = element.getBoundingClientRect(); - menu.hidden = false; - menu.style.left = `${Math.min(r.right, window.innerWidth - 180)}px`; - menu.style.top = `${Math.min(r.top, window.innerHeight - 100)}px`; - menu.querySelector("button")?.focus(); - }; - element("edit-agent", HTMLButtonElement).addEventListener("click", () => { - menu.hidden = true; - void editor.open(selectedId, anchor); + const menu = createActionMenu({ + id: "agent-actions", + label: "에이전트 메뉴", + align: "end", + items: [ + { + id: "edit-agent", + label: "프로필 및 설정", + icon: createElement(SettingsIcon), + run: (opener) => void editor.open(selectedId, opener), + }, + { + id: "open-mind", + label: "마음 상태 보기", + icon: createElement(MindIcon), + run: (opener) => void openMind(selectedId, opener), + }, + ], }); - element("open-mind", HTMLButtonElement).addEventListener("click", () => { - menu.hidden = true; - void openMind(selectedId, anchor); - }); - menu.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - event.preventDefault(); - menu.hidden = true; - anchor.focus(); - } - }); - document.addEventListener("pointerdown", (event) => { - if (event.target instanceof Node && !menu.contains(event.target)) - menu.hidden = true; - }); - const avatar = (profile: AgentProfile) => { - const value = document.createElement("span"); - value.className = "avatar"; - if (profile.avatarId) { - const img = document.createElement("img"); - img.src = `/api/avatars/${profile.avatarId}`; - img.alt = ""; - value.append(img); - } else value.textContent = profile.name.slice(0, 1); - return value; + const closeMenu = () => menu.close(); + const openMenu = (id: string, opener: HTMLElement) => { + selectedId = id; + menu.open(opener); }; - primary.addEventListener("contextmenu", (event) => { + window.addEventListener("resize", closeMenu); + primary?.addEventListener("click", () => void navigate(currentId)); + primary?.addEventListener("contextmenu", (event) => { event.preventDefault(); openMenu(currentId, primary); }); @@ -234,76 +233,115 @@ export function createAgents(currentId: string, preserve: () => boolean) { element("add-agent", HTMLButtonElement).addEventListener("click", (event) => { if (preserve()) templates.open(event.currentTarget as HTMLButtonElement); }); - async function refresh() { - try { - const data = (await agentRequest("/api/agents")) as { - agents: Summary[]; - presets: AgentInput[]; - }; - agents = data.agents; - const current = agents.find((a) => a.id === currentId); - if (current) { - document.title = `${displayName(current.name)} · Lina`; - setText( - element("chat-agent-name", HTMLElement), - displayName(current.name), - ); - setText( - primary.querySelector("strong") ?? primary, - displayName(current.name), - ); - const old = primary.querySelector(".avatar"); - if (old) old.replaceWith(avatar(current)); - primary.setAttribute("aria-label", `${current.name} 대화`); + const renderList = createAgentListView(model, list, status, { + select: (id) => void navigate(id), + menu: openMenu, + retry: () => void refresh(), + }); + const updateLabels = () => { + const current = agents.find((agent) => agent.id === currentId); + if (!current) return; + document.title = `${displayName(current.name)} · Lina`; + setText(element("chat-agent-name", HTMLElement), displayName(current.name)); + element("current-agent-settings", HTMLButtonElement).setAttribute( + "aria-label", + `${current.name} 프로필과 설정`, + ); + }; + const loadedDrafts = new Set(); + let inFlight: Promise | undefined; + function refresh(): Promise { + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const data = (await agentRequest("/api/agents")) as { + agents: AgentListProfile[]; + summaries?: AgentConversationSummary[]; + }; + agents = data.agents; + model.replace(agents); + for (const profile of agents) { + if (loadedDrafts.has(profile.id)) continue; + model.setDraft( + profile.id, + browserDraftStore(() => localStorage, profile.id).draft(), + ); + loadedDrafts.add(profile.id); + } + if (data.summaries) + for (const summary of data.summaries) model.updateSummary(summary); + else model.summariesFailed(); + updateLabels(); + } catch { + model.fail(); } - const next = JSON.stringify( - agents.map((a) => [a.id, a.name, a.role, a.avatarId, a.state]), - ); - if (signature === next) return; - signature = next; - list.replaceChildren( - ...agents - .filter((a) => a.id !== currentId) - .map((profile) => { - const row = document.createElement("div"); - row.className = "agent-row"; - const button = document.createElement("button"); - button.type = "button"; - button.className = "agent-link"; - button.setAttribute("aria-label", `${profile.name} 대화`); - button.append(avatar(profile)); - const copy = document.createElement("span"); - copy.className = "agent-copy sidebar-label"; - const title = document.createElement("strong"); - title.textContent = displayName(profile.name); - const sub = document.createElement("small"); - sub.textContent = - profile.state === "running" ? "작업 중" : profile.role; - copy.append(title, sub); - button.append(copy); - button.addEventListener("click", () => navigate(profile.id)); - button.addEventListener("contextmenu", (event) => { - event.preventDefault(); - openMenu(profile.id, button); - }); - const more = document.createElement("button"); - more.type = "button"; - more.className = "agent-more sidebar-label"; - more.textContent = "···"; - more.setAttribute("aria-label", `${profile.name} 메뉴`); - more.addEventListener("click", () => openMenu(profile.id, more)); - row.append(button, more); - return row; - }), - ); - } catch { - /* Legacy isolated fixtures can omit management APIs. Conversation stays usable. */ - } + renderList(); + })().finally(() => { + inFlight = undefined; + }); + return inFlight; } + filter.addEventListener("input", () => { + model.setFilter(filter.value); + renderList(); + }); + const updateDraft = (id: string, text: string) => { + loadedDrafts.add(id); + model.setDraft(id, text); + renderList(); + }; + document.getElementById("message")?.addEventListener("input", (event) => { + if (event.target instanceof HTMLTextAreaElement) + updateDraft(currentId, event.target.value); + }); + window.addEventListener("storage", () => { + for (const profile of agents) + model.setDraft( + profile.id, + browserDraftStore(() => localStorage, profile.id).draft(), + ); + renderList(); + }); + renderList(); void refresh(); const interval = setInterval(() => { if (!document.hidden) void refresh(); }, 15000); window.addEventListener("pagehide", () => clearInterval(interval)); - return { refresh }; + return { + refresh, + cancelNavigation() { + navigationGeneration++; + }, + updateDraft, + setCurrent(id: string) { + navigationGeneration++; + currentId = id; + model.setCurrent(id); + closeMenu(); + updateLabels(); + renderList(); + }, + updateSummary(summary: AgentConversationSummary) { + model.updateSummary(summary); + renderList(); + }, + updateSnapshot(snapshot: SessionSnapshot, confirmationCount?: number) { + const previous = model.summary(snapshot.botId); + model.updateSummary( + summaryFromSnapshot( + snapshot, + confirmationCount ?? + (previous?.sessionId === snapshot.sessionId + ? previous.confirmationCount + : null), + ), + ); + renderList(); + }, + markVisible(agentId: string, sessionId: string, seq: number) { + if (!document.hidden) model.markVisible(agentId, sessionId, seq); + renderList(); + }, + }; } diff --git a/packages/lina-web/client/app.ts b/packages/lina-ui/client/app.ts similarity index 100% rename from packages/lina-web/client/app.ts rename to packages/lina-ui/client/app.ts diff --git a/packages/lina-ui/client/attachments.css b/packages/lina-ui/client/attachments.css new file mode 100644 index 0000000..69d4704 --- /dev/null +++ b/packages/lina-ui/client/attachments.css @@ -0,0 +1,28 @@ +#draft-files .file-chip { + flex-wrap: wrap; + max-width: min(100%, 360px); +} +#draft-files .attachment-name { + flex: 1 1 100px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#draft-files .attachment-status { + flex: 1 0 100%; + order: 1; + overflow-wrap: anywhere; + word-break: keep-all; + color: var(--muted); +} +#draft-files .file-chip button { + flex: 0 0 auto; + min-width: 24px; + white-space: nowrap; +} +#draft-files .file-chip img, +#draft-files .file-chip canvas { + flex: 0 0 40px; + object-fit: contain; +} diff --git a/packages/lina-ui/client/attachments.ts b/packages/lina-ui/client/attachments.ts new file mode 100644 index 0000000..33b65fa --- /dev/null +++ b/packages/lina-ui/client/attachments.ts @@ -0,0 +1,284 @@ +import { + AttachmentDraft, + attachmentHref, + type DraftAttachment, + MAX_DRAFT_ATTACHMENTS, +} from "../../lina-client/src/attachment-draft.ts"; +import { + ATTACHMENT_UPLOAD_MAX_BYTES, + AttachmentQueue, + type AttachmentQueueItem, + type AttachmentSource, +} from "../../lina-client/src/attachment-queue.ts"; +import { installClipboardAttachments } from "./clipboard-attachments.ts"; +import { element } from "./render.ts"; + +export function createAttachments( + input: HTMLTextAreaElement, + session: () => string | undefined, + changed: () => void, + notice: (text: string) => void, +) { + const draft = new AttachmentDraft(); + const list = element("draft-files", HTMLDivElement); + const picker = element("file-picker", HTMLInputElement); + const button = element("attach-file", HTMLButtonElement); + let connected = false, + signature = ""; + const queue = new AttachmentQueue(draft, { + session, + text: () => input.value, + notice, + changed: () => { + render(); + changed(); + }, + }); + list.setAttribute("aria-label", "첨부 파일"); + list.setAttribute("aria-live", "polite"); + const render = () => { + const next = JSON.stringify([session(), connected, queue.items]); + if (signature !== next) { + signature = next; + list.replaceChildren(); + list.hidden = queue.items.length === 0; + for (const item of queue.items) { + const row = + item.state === "ready" + ? fileChip(item.ref, session()) + : pendingChip(item); + row.setAttribute("data-attachment-state", item.state); + if (item.state === "ready" && item.owner === session()) { + const status = document.createElement("span"); + status.className = "attachment-status"; + status.textContent = "준비됨"; + row.append(status); + if (/\.(png|jpe?g)$/i.test(item.name)) { + const image = document.createElement("img"); + image.src = attachmentHref(item.ref, "/preview"); + image.alt = `${item.name} 썸네일`; + image.width = image.height = 40; + image.addEventListener("error", () => { + image.hidden = true; + }); + row.append(image); + } + } + if (item.state === "error") { + const retry = document.createElement("button"); + retry.type = "button"; + retry.textContent = "재시도"; + retry.setAttribute("aria-label", `${item.name} 첨부 재시도`); + retry.disabled = !connected || item.owner !== session(); + retry.addEventListener("click", () => { + queue.retry(item.key); + input.focus(); + }); + row.append(retry); + } + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "×"; + remove.setAttribute("aria-label", `${item.name} 첨부 제외`); + remove.addEventListener("click", () => { + queue.remove(item.key); + input.focus(); + }); + row.append(remove); + list.append(row); + } + } + button.disabled = + !connected || !session() || queue.items.length >= MAX_DRAFT_ATTACHMENTS; + }; + const port = { + get value() { + draft.text = input.value; + return draft.value; + }, + set value(text: string) { + queue.reset(text); + input.value = draft.text; + render(); + }, + }; + const addFiles = ( + files: readonly File[], + source: AttachmentSource = "picker", + ) => queue.addFiles(files, source); + const openPicker = () => picker.click(); + const picked = () => { + const files = Array.from(picker.files ?? []); + picker.value = ""; + addFiles(files, "picker"); + }; + button.addEventListener("click", openPicker); + picker.addEventListener("change", picked); + const removePaste = installClipboardAttachments(input, (files) => + addFiles(files, "paste"), + ); + return { + port, + addFiles, + get busy() { + return queue.busy; + }, + get blocked() { + return queue.blocked; + }, + update(online: boolean) { + connected = online; + queue.update(online); + render(); + }, + dispose() { + queue.update(false); + removePaste(); + button.removeEventListener("click", openPicker); + picker.removeEventListener("change", picked); + }, + }; +} + +function pendingChip( + item: Exclude, +): HTMLElement { + const row = document.createElement("span"); + row.className = "file-chip"; + const name = document.createElement("span"); + name.className = "attachment-name"; + name.textContent = item.name; + name.title = item.name; + const status = document.createElement("span"); + status.className = "attachment-status"; + status.textContent = + item.state === "error" + ? (item.error ?? "첨부 실패") + : item.state === "pending" + ? "대기 중" + : "첨부 중…"; + if (item.state === "error") status.setAttribute("role", "alert"); + row.append(name, status); + if ( + ["image/png", "image/jpeg"].includes(item.file.type) && + item.file.size <= ATTACHMENT_UPLOAD_MAX_BYTES && + typeof createImageBitmap === "function" + ) { + // Canvas keeps local thumbnails within the existing self-only image CSP. + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 40; + canvas.setAttribute("role", "img"); + canvas.setAttribute("aria-label", `${item.name} 썸네일`); + row.append(canvas); + void createImageBitmap(item.file) + .then((bitmap) => { + try { + if (!canvas.isConnected) return; + const context = canvas.getContext("2d"); + const scale = Math.min(40 / bitmap.width, 40 / bitmap.height); + context?.drawImage( + bitmap, + 0, + 0, + bitmap.width * scale, + bitmap.height * scale, + ); + } finally { + bitmap.close(); + } + }) + .catch(() => { + canvas.hidden = true; + }); + } + return row; +} +function fileChip( + ref: DraftAttachment, + sessionId: string | undefined, +): HTMLElement { + const row = document.createElement("span"); + row.className = "file-chip"; + if (ref.sessionId !== sessionId) { + row.textContent = `${ref.name} · ${sessionId ? "다른 대화의 첨부" : "연결 후 확인"}`; + return row; + } + const link = document.createElement("a"); + link.href = attachmentHref(ref); + link.textContent = ref.name; + link.title = ref.name; + link.download = ref.name; + const preview = document.createElement("button"); + preview.type = "button"; + preview.textContent = "미리보기"; + preview.addEventListener("click", () => void showPreview(ref)); + row.append(link, preview); + return row; +} +export function renderUserBody( + container: Element, + text: string, + sessionId: string | undefined, +): void { + const draft = new AttachmentDraft(); + draft.value = text; + container.replaceChildren(document.createTextNode(draft.text)); + if (draft.refs.length) { + const files = document.createElement("div"); + files.className = "message-files"; + files.append(...draft.refs.map((ref) => fileChip(ref, sessionId))); + container.append(files); + } +} +async function showPreview(ref: DraftAttachment): Promise { + const dialog = document.createElement("dialog"); + dialog.className = "file-preview"; + dialog.setAttribute("aria-label", `${ref.name} 미리보기`); + const title = document.createElement("strong"); + title.textContent = ref.name; + const close = document.createElement("button"); + close.type = "button"; + close.textContent = "닫기"; + close.addEventListener("click", () => dialog.close()); + const body = document.createElement("div"); + body.textContent = "불러오는 중"; + dialog.append(title, close, body); + document.body.append(dialog); + dialog.addEventListener("close", () => dialog.remove(), { once: true }); + dialog.showModal(); + try { + const response = await fetch(attachmentHref(ref, "/preview"), { + signal: AbortSignal.timeout(10000), + redirect: "error", + }); + if (!response.ok) throw new Error("미리보기 실패"); + const mime = response.headers.get("Content-Type") ?? ""; + if (mime.startsWith("image/png") || mime.startsWith("image/jpeg")) { + const image = document.createElement("img"); + image.alt = ref.name; + // Same-origin URL, not a data/blob URL; server validates format and dimensions. + image.src = attachmentHref(ref, "/preview"); + image.addEventListener( + "error", + () => { + body.textContent = "이미지 표시 실패 · 다운로드 가능"; + }, + { once: true }, + ); + body.replaceChildren(image); + await response.body?.cancel(); + } else if (mime.startsWith("text/plain")) { + const text = await response.text(); + const pre = document.createElement("pre"); + pre.textContent = + text.slice(0, 8192) + + (text.length > 8192 ? "\n… 전체 내용은 다운로드로 확인" : ""); + body.replaceChildren(pre); + } else { + await response.body?.cancel(); + throw new Error("미리보기 미지원"); + } + } catch (error) { + body.textContent = error instanceof Error ? error.message : "미리보기 실패"; + } +} diff --git a/packages/lina-web/client/base.css b/packages/lina-ui/client/base.css similarity index 80% rename from packages/lina-web/client/base.css rename to packages/lina-ui/client/base.css index b4c915c..0124d64 100644 --- a/packages/lina-web/client/base.css +++ b/packages/lina-ui/client/base.css @@ -1,7 +1,7 @@ :root { font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Apple SD Gothic Neo", - "Noto Sans CJK KR", sans-serif; + -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", + "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", sans-serif; color: var(--text); background: var(--app-bg); font-synthesis: none; @@ -11,10 +11,12 @@ } body { margin: 0; - font-size: 0.9375rem; + font-size: var(--font-ui); + line-height: 1.5; } button, input, +select, textarea { font: inherit; } @@ -39,7 +41,7 @@ input:focus-visible, summary:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); - outline-offset: 4px; + outline-offset: 2px; } body [hidden] { display: none; @@ -64,7 +66,6 @@ h3 { min(420px, 45vw) ) minmax(0, 1fr); height: 100dvh; - max-width: 1600px; margin: auto; background: var(--surface-main); } diff --git a/packages/lina-web/client/chat.css b/packages/lina-ui/client/chat.css similarity index 86% rename from packages/lina-web/client/chat.css rename to packages/lina-ui/client/chat.css index b945f35..dc20c2f 100644 --- a/packages/lina-web/client/chat.css +++ b/packages/lina-ui/client/chat.css @@ -5,16 +5,16 @@ flex-direction: column; } .chat-header { - min-height: 76px; - padding: 17px 28px; + min-height: 56px; + padding: 8px 24px; display: flex; align-items: center; justify-content: space-between; - gap: 16px; - border-bottom: 1px solid var(--line); + gap: 8px; + flex-shrink: 0; } h1 { - font-size: 1.2rem; + font-size: var(--font-ui); font-weight: 600; } .connection-controls { @@ -47,14 +47,15 @@ h1 { } .text-button { border: 0; - padding: 9px; + padding: 6px 10px; + min-height: var(--control-size); background: transparent; color: var(--accent); - font-size: 0.72rem; - border-radius: 6px; + font-size: var(--font-caption); + border-radius: var(--control-radius); } .text-button:hover { - background: var(--tint); + background: var(--hover); } .notice { margin: 16px 30px 0; @@ -71,7 +72,7 @@ h1 { min-height: 0; overflow-y: auto; overscroll-behavior: contain; - scrollbar-gutter: stable; + scrollbar-gutter: stable both-edges; } .welcome { min-height: 100%; @@ -129,25 +130,25 @@ h1 { background: var(--tint); } .messages { - max-width: 820px; + max-width: var(--conversation-width); margin: auto; - padding: 26px 30px 10px; + padding: 24px 24px 12px; } .message { - margin-bottom: 27px; + margin-bottom: 28px; } .message-body { white-space: pre-wrap; word-break: keep-all; overflow-wrap: anywhere; - line-height: 1.8; + line-height: 1.75; font-size: var(--chat-text-size, 0.9375rem); margin-left: 0; } .message.user .message-body { background: var(--surface-raised); - padding: 13px 16px; - border-radius: 9px; + padding: 10px 16px; + border-radius: 18px; } .message-footer { display: flex; @@ -175,12 +176,8 @@ h1 { max-width: 85%; margin-left: auto; } -.message.user .message-body { - border-radius: 16px 16px 4px 16px; - padding: 10px 15px; -} .message.assistant { - max-width: 95%; + max-width: 100%; } .message.user .message-footer { justify-content: flex-end; diff --git a/packages/lina-ui/client/clipboard-attachments.ts b/packages/lina-ui/client/clipboard-attachments.ts new file mode 100644 index 0000000..8b7d92e --- /dev/null +++ b/packages/lina-ui/client/clipboard-attachments.ts @@ -0,0 +1,31 @@ +type ClipboardData = Pick; + +/** Snapshot one collection per event; Chromium wrappers may differ in lastModified. */ +export function clipboardFiles(data: ClipboardData): File[] { + const items = Array.from(data.items).flatMap((item) => { + if (item.kind !== "file") return []; + const file = item.getAsFile(); + return file ? [file] : []; + }); + return items.length ? items : Array.from(data.files); +} + +export function installClipboardAttachments( + input: HTMLTextAreaElement, + addFiles: (files: File[]) => void, +): () => void { + const paste = (event: ClipboardEvent) => { + if (event.defaultPrevented || !event.clipboardData) return; + const files = clipboardFiles(event.clipboardData); + if (!files.length) return; + const text = event.clipboardData.getData("text/plain"); + event.preventDefault(); + if (text) { + input.setRangeText(text, input.selectionStart, input.selectionEnd, "end"); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + addFiles(files); + }; + input.addEventListener("paste", paste); + return () => input.removeEventListener("paste", paste); +} diff --git a/packages/lina-web/client/companion.css b/packages/lina-ui/client/companion.css similarity index 73% rename from packages/lina-web/client/companion.css rename to packages/lina-ui/client/companion.css index 1b927cc..5c02725 100644 --- a/packages/lina-web/client/companion.css +++ b/packages/lina-ui/client/companion.css @@ -10,19 +10,19 @@ gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); - font-size: 0.82rem; + font-size: var(--font-ui); } .model-role-row select { min-width: 0; padding: 7px; border: 1px solid var(--input-border); - border-radius: 6px; + border-radius: var(--control-radius); background: var(--input-bg); color: var(--text); } .model-effective { color: var(--muted); - font-size: 0.72rem; + font-size: var(--font-caption); line-height: 1.6; padding-top: 10px; } @@ -32,14 +32,14 @@ margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); - font-size: 0.82rem; + font-size: var(--font-ui); } .model-trial select, .model-trial textarea { width: 100%; padding: 8px; border: 1px solid var(--input-border); - border-radius: 6px; + border-radius: var(--control-radius); background: var(--input-bg); color: var(--text); resize: vertical; @@ -94,79 +94,81 @@ align-items: start; padding: 12px 0; border-bottom: 1px solid var(--line); - font-size: 0.82rem; + font-size: var(--font-ui); } .model-config-row > label { - padding-top: 12px; + padding-top: 8px; } .model-config-row select, .model-combobox input { width: 100%; min-width: 0; - min-height: 42px; + min-height: 36px; border: 1px solid var(--input-border); - border-radius: 8px; + border-radius: var(--control-radius); background: var(--input-bg); color: var(--text); font: inherit; - padding: 9px; + padding: 7px 10px; } .model-combobox { min-width: 0; position: relative; } -.model-combobox[data-open="true"] { - z-index: 4; -} .model-combobox input { padding-right: 22px; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: - calc(100% - 13px) 18px, - calc(100% - 9px) 18px; + calc(100% - 13px) 16px, + calc(100% - 9px) 16px; background-size: 4px 4px; background-repeat: no-repeat; text-overflow: ellipsis; } +.model-combobox-positioner { + z-index: 60; + width: var(--anchor-width); + max-width: calc(100vw - 16px); + outline: none; +} +.model-combobox-popup { + max-height: var(--available-height); + overflow: hidden; + padding: 4px; + border: 1px solid var(--line); + border-radius: var(--menu-radius); + background: var(--surface-raised); + color: var(--text); + box-shadow: 0 8px 32px #0003; + outline: none; +} .model-combobox-list { - position: absolute; - left: 0; - right: 0; - top: calc(100% + 5px); - z-index: 4; - max-height: min(220px, 40dvh); + max-height: min(260px, calc(var(--available-height) - 10px)); overflow: auto; overscroll-behavior: contain; - padding: 4px; - border: 1px solid var(--input-border); - border-radius: 10px; - background: var(--input-bg); -} -.model-combobox[data-placement="above"] .model-combobox-list { - top: auto; - bottom: calc(100% + 5px); -} -.model-combobox-list[hidden] { - display: none; } .model-combobox-list [role="option"] { - padding: 11px 9px; - min-height: 42px; - border-radius: 6px; - cursor: pointer; + padding: 8px 10px; + min-height: 36px; + border-radius: var(--control-radius); + cursor: default; overflow-wrap: anywhere; - line-height: 1.5; + line-height: 20px; } .model-combobox-list [role="option"]:hover, -.model-combobox-list [data-active="true"] { - background: color-mix(in srgb, var(--accent) 18%, var(--input-bg)); +.model-combobox-list [data-highlighted] { + background: var(--selection); } .model-combobox-list [aria-selected="true"] { color: var(--accent); font-weight: 600; } +.model-combobox-popup .agent-hint { + margin: 0; + padding: 8px; +} .model-combobox input:focus-visible, .model-config-row select:focus-visible { outline: 2px solid var(--accent); @@ -180,7 +182,7 @@ grid-column: 2 / -1; margin: 0; color: var(--muted); - font-size: 0.72rem; + font-size: var(--font-caption); line-height: 1.5; } .model-row-hint:empty { @@ -219,6 +221,14 @@ } .model-role-row select { width: 100%; - min-height: 42px; + min-height: 36px; font: inherit; } + +@media (max-width: 767px) { + .model-config-row select, + .model-combobox input, + .model-combobox-list [role="option"] { + min-height: 44px; + } +} diff --git a/packages/lina-ui/client/components/action-menu.tsx b/packages/lina-ui/client/components/action-menu.tsx new file mode 100644 index 0000000..524d2a7 --- /dev/null +++ b/packages/lina-ui/client/components/action-menu.tsx @@ -0,0 +1,137 @@ +import { createRef, type ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { mountView } from "./mount.ts"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, +} from "./ui/dropdown-menu.tsx"; + +interface Action { + id: string; + label: string; + icon?: ReactNode; + shortcut?: string; + run(opener: HTMLElement): void; +} + +/** Existing controllers own triggers; this adapter owns the entire menu subtree. */ +export function createActionMenu(options: { + id: string; + label: string; + items: Action[]; + side?: "top" | "bottom"; + align?: "start" | "end"; +}) { + const host = document.createElement("div"); + host.dataset["uiHost"] = options.id; + document.body.append(host); + const view = mountView(host); + const actionsRef = createRef<{ close(): void; unmount(): void }>(); + let anchor: HTMLElement | null = null; + let opened = false; + let restoreFocus = false; + const render = () => + view.render( + { + // The trigger belongs to the controller, so Base UI does not know it. + // Let its click toggle once instead of outside-press closing then reopening. + if ( + details.reason === "outside-press" && + details.event.target instanceof Node && + anchor?.contains(details.event.target) + ) { + details.cancel(); + return; + } + if (!next) restoreFocus = details.reason === "escape-key"; + opened = next; + anchor?.setAttribute("aria-expanded", String(next)); + render(); + }} + > + { + if (event.key !== "Tab") return; + // External triggers have no Base UI focus guard. Resume the + // browser's native Tab/Shift+Tab order at the actual opener. + close(); + flushSync(() => actionsRef.current?.unmount()); + if (anchor?.isConnected) anchor.focus({ preventScroll: true }); + }} + finalFocus={() => + restoreFocus && anchor?.isConnected ? anchor : false + } + > + {options.items.map((item) => ( + } + onClick={() => { + const opener = anchor; + close(); + if (opener) item.run(opener); + }} + > + {item.icon} + {item.label} + {item.shortcut && {item.shortcut}} + + ))} + + , + ); + function close() { + restoreFocus = false; + opened = false; + anchor?.setAttribute("aria-expanded", "false"); + render(); + } + function open(opener: HTMLElement, edge: "first" | "last" = "first") { + anchor?.setAttribute("aria-expanded", "false"); + anchor = opener; + opened = true; + restoreFocus = false; + opener.setAttribute("aria-haspopup", "menu"); + opener.setAttribute("aria-controls", options.id); + opener.setAttribute("aria-expanded", "true"); + render(); + const item = edge === "first" ? options.items[0] : options.items.at(-1); + if (item) document.getElementById(item.id)?.focus({ preventScroll: true }); + } + const bindings: Array<() => void> = []; + render(); + return { + open, + close, + bindTrigger(trigger: HTMLElement) { + const click = () => (opened ? close() : open(trigger)); + const keydown = (event: KeyboardEvent) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + open(trigger, event.key === "ArrowUp" ? "last" : "first"); + }; + trigger.addEventListener("click", click); + trigger.addEventListener("keydown", keydown); + bindings.push(() => { + trigger.removeEventListener("click", click); + trigger.removeEventListener("keydown", keydown); + }); + }, + destroy() { + for (const unbind of bindings) unbind(); + view.destroy(); + host.remove(); + }, + }; +} diff --git a/packages/lina-ui/client/components/agent-list.tsx b/packages/lina-ui/client/components/agent-list.tsx new file mode 100644 index 0000000..f40c098 --- /dev/null +++ b/packages/lina-ui/client/components/agent-list.tsx @@ -0,0 +1,253 @@ +import type { KeyboardEvent, PointerEvent } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { AgentListRow } from "../../../lina-client/src/agent-list.ts"; +import { Avatar } from "./ui/avatar.tsx"; +import { Button } from "./ui/button.tsx"; + +export type AgentListActions = { + select(id: string): void; + menu(id: string, opener: HTMLElement): void; + retry(): void; +}; + +const LONG_PRESS_MS = 550; +const PRESS_MOVE_TOLERANCE = 10; + +function messageTime(timestamp: string | null): string { + if (!timestamp) return ""; + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) return ""; + const today = new Date(); + if (date.toDateString() === today.toDateString()) + return date.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + today.setDate(today.getDate() - 1); + return date.toDateString() === today.toDateString() + ? "어제" + : date.toLocaleDateString("ko-KR", { + month: "numeric", + day: "numeric", + ...(date.getFullYear() !== today.getFullYear() + ? { year: "2-digit" } + : {}), + }); +} + +function AgentRow({ + row, + actions, + onNavigate, + linkRef, +}: { + row: AgentListRow; + actions: AgentListActions; + onNavigate(event: KeyboardEvent): void; + linkRef(node: HTMLButtonElement | null): void; +}) { + const more = useRef(null); + const longPressed = useRef(false); + const press = useRef<{ + timer: ReturnType; + controller: AbortController; + pointerId: number; + x: number; + y: number; + } | null>(null); + const cancelPress = useCallback(() => { + if (!press.current) return; + clearTimeout(press.current.timer); + press.current.controller.abort(); + press.current = null; + }, []); + useEffect(() => cancelPress, [cancelPress]); + const openMenu = () => { + cancelPress(); + if (more.current) actions.menu(row.id, more.current); + }; + const startPress = (event: PointerEvent) => { + cancelPress(); + longPressed.current = false; + if (event.pointerType !== "touch" || !event.isPrimary) return; + const controller = new AbortController(); + // Scroll events do not bubble. Capture also catches a scrollable ancestor. + event.currentTarget.ownerDocument.addEventListener("scroll", cancelPress, { + capture: true, + signal: controller.signal, + }); + press.current = { + controller, + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + timer: setTimeout(() => { + longPressed.current = true; + openMenu(); + }, LONG_PRESS_MS), + }; + }; + const contextKeys = (event: KeyboardEvent) => { + if ( + event.key === "ContextMenu" || + (event.shiftKey && event.key === "F10") + ) { + event.preventDefault(); + openMenu(); + } + }; + const time = messageTime(row.timestamp); + const confirmation = (row.confirmationCount ?? 0) > 0; + return ( + // The wrapper shares context-menu shortcuts between its two native buttons. + // biome-ignore lint/a11y/noStaticElementInteractions: keyboard activation lives on the descendant buttons. +
{ + event.preventDefault(); + openMenu(); + }} + onKeyDown={contextKeys} + > + + +
+ ); +} + +export function AgentList({ + rows, + actions, +}: { + rows: AgentListRow[]; + actions: AgentListActions; +}) { + const links = useRef(new Map()); + const navigate = (event: KeyboardEvent, index: number) => { + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? rows.length - 1 + : Math.max( + 0, + Math.min( + rows.length - 1, + index + (event.key === "ArrowDown" ? 1 : -1), + ), + ); + const row = rows[next]; + if (row) links.current.get(row.id)?.focus(); + }; + return rows.map((row, index) => ( + navigate(event, index)} + linkRef={(node) => { + if (node) links.current.set(row.id, node); + else links.current.delete(row.id); + }} + /> + )); +} + +export function AgentListStatus({ + copy, + retry, + onRetry, +}: { + copy: string; + retry: boolean; + onRetry(): void; +}) { + return ( + <> + {copy} + + + ); +} diff --git a/packages/lina-ui/client/components/icons.tsx b/packages/lina-ui/client/components/icons.tsx new file mode 100644 index 0000000..a0cc33d --- /dev/null +++ b/packages/lina-ui/client/components/icons.tsx @@ -0,0 +1,24 @@ +export function SettingsIcon() { + return ( + + ); +} +export function KeyboardIcon() { + return ( + + ); +} +export function MindIcon() { + return ( + + ); +} diff --git a/packages/lina-ui/client/components/model-combobox.tsx b/packages/lina-ui/client/components/model-combobox.tsx new file mode 100644 index 0000000..28bc85f --- /dev/null +++ b/packages/lina-ui/client/components/model-combobox.tsx @@ -0,0 +1,132 @@ +import { type Ref, useImperativeHandle, useRef, useState } from "react"; +import type { ModelChoice } from "../model-combobox-types.ts"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "./ui/combobox.tsx"; + +interface Selection { + items: ModelChoice[]; + value: string; + label: string; + disabled: boolean; +} +export interface ModelComboboxHandle { + input: HTMLInputElement | null; + set(selection: Selection): void; + disable(disabled: boolean): void; + close(): void; +} + +const normalize = (text: string) => + text.trim().replace(/\s+/g, " ").toLocaleLowerCase(); +function matches(item: ModelChoice, query: string) { + const text = normalize(`${item.label} ${item.search ?? ""}`); + return normalize(query) + .split(" ") + .every((part) => text.includes(part)); +} + +export function ModelCombobox({ + id, + label, + onChange, + ref, +}: { + id: string; + label: string; + onChange(value: string): void; + ref: Ref; +}) { + const input = useRef(null); + const [selection, setSelection] = useState({ + items: [], + value: "", + label: "모델 선택…", + disabled: false, + }); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(null); + const close = () => { + setOpen(false); + setQuery(null); + }; + useImperativeHandle(ref, () => ({ + input: input.current, + set(next) { + setSelection(next); + if (next.disabled) close(); + }, + disable(disabled) { + setSelection((current) => ({ ...current, disabled })); + if (disabled) close(); + }, + close, + })); + // Retain catalog order until searching; concrete models precede inheritance matches. + const items = query?.trim() + ? [...selection.items].sort( + (a, b) => Number(!!a.inherited) - Number(!!b.inherited), + ) + : selection.items; + const dialog = input.current?.closest("dialog"); + return ( + item.value === value.value} + inputValue={query ?? selection.label} + onInputValueChange={(value, details) => { + if (details.reason === "input-change") setQuery(value); + }} + onValueChange={(item) => { + // Editing the query must not clear the model or select inheritance. + if (!item) return; + setSelection((current) => ({ + ...current, + value: item.value, + label: item.label, + })); + onChange(item.value); + }} + open={open} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (!nextOpen) setQuery(null); + }} + disabled={selection.disabled} + > + + + +

검색 결과가 없습니다.

+
+ + {(item: ModelChoice) => ( + + {item.label} + + )} + +
+
+ ); +} diff --git a/packages/lina-ui/client/components/mount.ts b/packages/lina-ui/client/components/mount.ts new file mode 100644 index 0000000..2682259 --- /dev/null +++ b/packages/lina-ui/client/components/mount.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; + +/** The controller owns the host; React alone owns all descendants. */ +export function mountView(host: HTMLElement) { + const root = createRoot(host); + return { + render(node: ReactNode) { + // Legacy callers may query/focus the view immediately after this call. + flushSync(() => root.render(node)); + }, + destroy() { + root.unmount(); + }, + }; +} diff --git a/packages/lina-ui/client/components/settings-tabs.tsx b/packages/lina-ui/client/components/settings-tabs.tsx new file mode 100644 index 0000000..d2d30e0 --- /dev/null +++ b/packages/lina-ui/client/components/settings-tabs.tsx @@ -0,0 +1,71 @@ +import { SETTINGS_TABS, type SettingsTab } from "../settings-tab-model.ts"; +import { SettingsIcon } from "./icons.tsx"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs.tsx"; + +const labels: Record = { + general: "일반", + connections: "프로바이더 연결", + models: "역할별 모델", +}; + +function SettingsTabIcon({ tab }: { tab: SettingsTab }) { + if (tab === "general") return ; + return ( + + ); +} + +export function SettingsTabsView({ + value, + orientation, + label, + select, +}: { + value: SettingsTab; + orientation: "horizontal" | "vertical"; + label: string; + select: (tab: SettingsTab) => void; +}) { + return ( + { + const tab = SETTINGS_TABS.find((id) => id === next); + if (tab) select(tab); + }} + render={ + + } + style={{ + flexDirection: orientation === "vertical" ? "column" : "row", + }} + > + {SETTINGS_TABS.map((tab) => ( + + + {labels[tab]} + + ))} + + ); +} diff --git a/packages/lina-ui/client/components/task-layout-control.tsx b/packages/lina-ui/client/components/task-layout-control.tsx new file mode 100644 index 0000000..1098f65 --- /dev/null +++ b/packages/lina-ui/client/components/task-layout-control.tsx @@ -0,0 +1,28 @@ +import { Button } from "./ui/button.tsx"; + +/** A secondary task action, separate from the global sidebar control. */ +export function TaskLayoutControl({ + active, + onToggle, +}: { + active: boolean; + onToggle(): void; +}) { + return ( + + ); +} diff --git a/packages/lina-ui/client/components/theme-controls.tsx b/packages/lina-ui/client/components/theme-controls.tsx new file mode 100644 index 0000000..2fbda7b --- /dev/null +++ b/packages/lina-ui/client/components/theme-controls.tsx @@ -0,0 +1,32 @@ +import type { Theme } from "../theme.ts"; +import { Button } from "./ui/button.tsx"; + +const choices: { value: Theme; label: string }[] = [ + { value: "dark", label: "다크" }, + { value: "light", label: "라이트" }, + { value: "system", label: "시스템" }, +]; + +export function ThemeControlsView({ + preference, + select, +}: { + preference: Theme; + select: (theme: Theme) => void; +}) { + return ( + <> + {choices.map(({ value, label }) => ( + + ))} + + ); +} diff --git a/packages/lina-ui/client/components/ui/avatar.tsx b/packages/lina-ui/client/components/ui/avatar.tsx new file mode 100644 index 0000000..98d7342 --- /dev/null +++ b/packages/lina-ui/client/components/ui/avatar.tsx @@ -0,0 +1,21 @@ +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; + +/** Base UI owns image loading and fallback visibility; Lina owns the geometry. */ +export function Avatar({ + src, + fallback, +}: { + src?: string | undefined; + fallback: string; +}) { + return ( + + ); +} diff --git a/packages/lina-ui/client/components/ui/button.tsx b/packages/lina-ui/client/components/ui/button.tsx new file mode 100644 index 0000000..2d080f5 --- /dev/null +++ b/packages/lina-ui/client/components/ui/button.tsx @@ -0,0 +1,22 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; + +/** shadcn base-nova Button, with Lina's shared CSS tokens instead of Tailwind. */ +export function Button({ + className = "", + variant = "ghost", + size = "default", + ...props +}: ButtonPrimitive.Props & { + variant?: "default" | "secondary" | "ghost" | "outline" | "destructive"; + size?: "default" | "icon"; +}) { + return ( + + ); +} diff --git a/packages/lina-ui/client/components/ui/combobox.tsx b/packages/lina-ui/client/components/ui/combobox.tsx new file mode 100644 index 0000000..55c750e --- /dev/null +++ b/packages/lina-ui/client/components/ui/combobox.tsx @@ -0,0 +1,34 @@ +import { Combobox as Primitive } from "@base-ui/react/combobox"; + +/** shadcn-style composition; Base UI owns widget interactions and positioning. */ +export const Combobox = Primitive.Root; +export const ComboboxInput = Primitive.Input; +export const ComboboxList = Primitive.List; +export const ComboboxItem = Primitive.Item; +export const ComboboxEmpty = Primitive.Empty; + +export function ComboboxContent({ + container, + collisionBoundary, + ...props +}: Primitive.Popup.Props & + Pick & + Pick) { + return ( + + + + + + ); +} diff --git a/packages/lina-ui/client/components/ui/dropdown-menu.tsx b/packages/lina-ui/client/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bc7cfe7 --- /dev/null +++ b/packages/lina-ui/client/components/ui/dropdown-menu.tsx @@ -0,0 +1,41 @@ +import { Menu } from "@base-ui/react/menu"; + +/** Adapted from shadcn base-nova; behavior stays in Base UI, skin in controls.css. */ +export const DropdownMenu = Menu.Root; + +export function DropdownMenuContent({ + anchor, + side = "bottom", + align = "start", + ...props +}: Menu.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +export function DropdownMenuItem(props: Menu.Item.Props) { + return ( + + ); +} diff --git a/packages/lina-ui/client/components/ui/tabs.tsx b/packages/lina-ui/client/components/ui/tabs.tsx new file mode 100644 index 0000000..21f6c37 --- /dev/null +++ b/packages/lina-ui/client/components/ui/tabs.tsx @@ -0,0 +1,19 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; + +/** shadcn base-nova Tabs slots, styled with Lina's existing CSS. */ +export function Tabs({ + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ); +} + +export function TabsList(props: TabsPrimitive.List.Props) { + return ; +} + +export function TabsTrigger(props: TabsPrimitive.Tab.Props) { + return ; +} diff --git a/packages/lina-web/client/composer-size.ts b/packages/lina-ui/client/composer-size.ts similarity index 100% rename from packages/lina-web/client/composer-size.ts rename to packages/lina-ui/client/composer-size.ts diff --git a/packages/lina-ui/client/composer.css b/packages/lina-ui/client/composer.css new file mode 100644 index 0000000..d016b22 --- /dev/null +++ b/packages/lina-ui/client/composer.css @@ -0,0 +1,124 @@ +.composer-area { + display: flex; + flex-direction: column; + min-height: 0; + padding: 12px 24px 24px; + width: 100%; + max-width: var(--conversation-width); + align-self: center; +} +.composer { + flex-shrink: 0; + border: 1px solid transparent; + border-radius: var(--composer-radius); + padding: 12px; + background: var(--surface-raised); +} +.composer:focus-within { + border-color: var(--line-strong); +} +textarea { + display: block; + width: 100%; + border: none; + outline: none; + resize: none; + min-height: 44px; + max-height: none; + background: transparent; + color: inherit; + font-size: var(--chat-text-size, 0.9375rem); + line-height: 1.7; +} +#message { + padding: 2px 4px 8px; +} +textarea:focus-visible { + outline: none; +} +textarea::placeholder { + color: var(--muted); +} +.composer-bottom { + display: flex; + gap: 4px; + align-items: center; + margin-top: 8px; +} +#attach-file, +:is(#send, #task-send) { + display: inline-grid; + place-items: center; + flex-shrink: 0; + width: var(--control-size); + height: var(--control-size); + min-height: var(--control-size); + padding: 0; + border: 0; + border-radius: 50%; +} +#attach-file { + background: transparent; + color: var(--text); +} +#attach-file:hover:not(:disabled) { + background: var(--hover); +} +#attach-file:disabled { + opacity: 0.5; +} +#composer-model { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + min-height: var(--control-size); + padding: 4px 8px; + color: var(--muted); + font-size: var(--font-caption); +} +#composer-model svg { + width: 14px; + height: 14px; +} +:is(#send, #task-send) { + margin-left: 4px; + color: var(--button-text); + background: var(--button-bg); +} +:is(#send, #task-send):hover:not(:disabled) { + background: var(--button-hover); +} +:is(#send, #task-send):disabled { + background: var(--disabled-bg); + color: var(--muted); +} +.work-panels { + min-height: 0; + max-height: 40dvh; + overflow: auto; +} +.execution-bar { + flex-shrink: 0; +} +.composer-expand { + color: var(--muted); + white-space: nowrap; +} +.intro-active #send { + margin-left: auto; +} +@media (max-width: 767px) { + .composer-area { + padding: 8px 12px max(12px, env(safe-area-inset-bottom)); + } + .composer { + padding: 10px; + } + .composer-bottom { + margin-top: 4px; + } + #message { + font-size: max(16px, var(--chat-text-size, 16px)); + } +} diff --git a/packages/lina-web/client/context-view.ts b/packages/lina-ui/client/context-view.ts similarity index 97% rename from packages/lina-web/client/context-view.ts rename to packages/lina-ui/client/context-view.ts index a5427ae..e12dc34 100644 --- a/packages/lina-web/client/context-view.ts +++ b/packages/lina-ui/client/context-view.ts @@ -1,8 +1,8 @@ +import type { ContextModel } from "../../lina-client/src/context-model.ts"; import type { ContextClient, ContextSnapshot, } from "../../lina-core/src/context-wire.ts"; -import type { ContextModel } from "./context-model.ts"; import { element, setText } from "./render.ts"; const STATUS: Record = { diff --git a/packages/lina-web/client/context.css b/packages/lina-ui/client/context.css similarity index 100% rename from packages/lina-web/client/context.css rename to packages/lina-ui/client/context.css diff --git a/packages/lina-ui/client/controls.css b/packages/lina-ui/client/controls.css new file mode 100644 index 0000000..3508d97 --- /dev/null +++ b/packages/lina-ui/client/controls.css @@ -0,0 +1,125 @@ +/* Shared shadcn/Base UI skin. All surfaces use the same semantic design tokens. */ +.ui-tabs-list { + display: flex; + gap: inherit; + width: 100%; + min-width: 0; +} +.ui-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: var(--control-size); + padding: 6px 10px; + border: 1px solid transparent; + border-radius: var(--control-radius); + background: transparent; + color: var(--text); + font: inherit; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + user-select: none; +} +.ui-button:hover, +.ui-button[aria-expanded="true"] { + background: var(--selection); +} +.ui-button[data-variant="default"] { + background: var(--button-bg); + color: var(--button-text); +} +.ui-button[data-variant="default"]:hover { + background: var(--button-hover); +} +.ui-button[data-variant="secondary"] { + background: var(--surface-raised); +} +.ui-button[data-variant="outline"] { + border-color: var(--line-strong); +} +.ui-button[data-variant="destructive"] { + color: var(--danger); +} +.ui-button[data-size="icon"] { + width: var(--control-size); + height: var(--control-size); + padding: 0; + flex-shrink: 0; +} +.ui-button:disabled, +.ui-button[data-disabled] { + opacity: 0.45; + cursor: not-allowed; +} +:is(.ui-button, .ui-menu-item) svg { + width: var(--icon-size); + height: var(--icon-size); + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; + pointer-events: none; +} +.ui-menu-positioner { + z-index: 50; + outline: none; + isolation: isolate; +} +.ui-menu { + min-width: 224px; + max-width: min(320px, calc(100vw - 16px)); + max-height: var(--available-height); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid var(--line); + border-radius: var(--menu-radius); + background: var(--surface-raised); + color: var(--text); + box-shadow: 0 8px 32px #0003; + outline: none; +} +.ui-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 36px; + padding: 8px 10px; + border: 0; + border-radius: var(--control-radius); + background: transparent; + color: inherit; + font: inherit; + font-weight: 400; + line-height: 20px; + text-align: left; + cursor: default; + outline: none; + user-select: none; +} +.ui-menu-item[data-highlighted] { + background: var(--selection); +} +.ui-menu-item svg, +.ui-menu-item kbd { + color: var(--muted); +} +.ui-menu-item kbd { + margin-left: auto; + font: inherit; + font-size: var(--font-caption); +} +.ui-menu-item:focus-visible { + outline: 1px solid var(--line-strong); + outline-offset: -1px; +} +@media (max-width: 767px) { + .ui-menu-item { + min-height: var(--control-size); + } +} diff --git a/packages/lina-ui/client/conversation-app.ts b/packages/lina-ui/client/conversation-app.ts new file mode 100644 index 0000000..775679b --- /dev/null +++ b/packages/lina-ui/client/conversation-app.ts @@ -0,0 +1,531 @@ +import { Connection } from "../../lina-client/src/connection.ts"; +import { ContextModel } from "../../lina-client/src/context-model.ts"; +import { browserDraftStore } from "../../lina-client/src/draft.ts"; +import { DurableChatModel } from "../../lina-client/src/durable-model.ts"; +import { ExecutionModel } from "../../lina-client/src/execution-model.ts"; +import { + type DesktopPlatform, + socketUrl, +} from "../../lina-client/src/platform.ts"; +import { shouldSend } from "../../lina-client/src/preferences.ts"; +import type { SessionSnapshot } from "../../lina-core/src/protocol.ts"; +import { createAgents } from "./agents.ts"; +import { createAttachments } from "./attachments.ts"; +import { createComposerSizing } from "./composer-size.ts"; +import { createExecutionView } from "./execution-view.ts"; +import { createNavigation } from "./navigation.ts"; +import { activityStatus, elapsedText } from "./presence.ts"; +import { installPwa } from "./pwa.ts"; +import { + captureReadingPosition, + loadReadingPosition, + type ReadingPosition, + restoreReadingPosition, + saveReadingPosition, +} from "./reading-position.ts"; +import { PendingRecovery } from "./recovery.ts"; +import { createRenderer, element, setText } from "./render.ts"; +import { createSearch } from "./search-view.ts"; +import { createSettings } from "./settings.ts"; +import { installTasks } from "./task-view.ts"; + +const NAVIGATION_DRAFT_NOTICE = + "첨부가 준비되고 초안이 저장된 뒤 이동할 수 있습니다."; + +export function startConversationApp(initialNotice = "") { + const settings = createSettings(); + const queryAgent = new URL(location.href).searchParams.get("agent") ?? "lina"; + let agentId = /^[a-z][a-z0-9-]{0,47}$/.test(queryAgent) ? queryAgent : "lina"; + + let model = new DurableChatModel(); + let execution = new ExecutionModel(); + let context = new ContextModel(); + let drafts = browserDraftStore(() => window.localStorage, agentId); + let publicSnapshot: SessionSnapshot | undefined; + let subscribing = false; + const input = element("message", HTMLTextAreaElement); + const send = element("send", HTMLButtonElement); + element("composer-model", HTMLButtonElement).addEventListener( + "click", + (event) => + settings.openForAgent(agentId, event.currentTarget as HTMLButtonElement), + ); + const composerSizing = createComposerSizing(input); + const notice = element("notice", HTMLDivElement); + const reconnect = element("reconnect", HTMLButtonElement); + const earlier = element("earlier", HTMLButtonElement); + const latest = element("latest", HTMLButtonElement); + let detail = initialNotice; + const attachments = createAttachments( + input, + () => model.sessionId, + () => { + recovery.edited(); + const saved = drafts.saveDraft(attachments.port.value); + if (saved && !attachments.blocked && detail === NAVIGATION_DRAFT_NOTICE) + detail = ""; + update(); + }, + (message) => { + detail = message; + update(); + }, + ); + const textPort = attachments.port; + const restore = (text: string): void => { + textPort.value = text; + recovery.edited(); + drafts.saveDraft(text); + input.focus(); + if (text.length > 16000) detail = "메시지 길이 초과 · 최대 16,000자"; + update(); + }; + let recovery = new PendingRecovery(drafts, textPort, restore, (message) => { + detail = message; + }); + const render = createRenderer( + () => model, + restore, + (id) => { + const message = model.messages.find((item) => item.id === id); + if ( + model.sessionId && + message?.sourceId && + typeof message.nextOffset === "number" + ) + connection.command({ + type: "entry", + sessionId: model.sessionId, + entryId: message.sourceId, + offset: message.nextOffset, + }); + }, + () => model.sessionId, + ); + + const renderExecution = createExecutionView( + () => execution, + (frame) => connection.command(frame), + update, + ); + + const search = createSearch((frame) => connection.command(frame)); + const tasks = installTasks({ + ownerAgentId: agentId, + onOpen: (id) => renderNavigation.taskOpened(id), + onShowList: () => renderNavigation.taskClosed(), + }); + void tasks.refresh(); + const readingPositions = new Map(); + const rememberPosition = () => { + if ( + !model.sessionId || + !element("conversation-scroll", HTMLDivElement).clientHeight + ) + return; + const position = captureReadingPosition(); + readingPositions.set(`${agentId}:${model.sessionId}`, position); + saveReadingPosition(agentId, model.sessionId, position); + }; + const preserve = () => { + if ( + attachments.blocked || + !tasks.canLeave() || + (textPort.value && !drafts.saveDraft(textPort.value)) + ) { + detail = NAVIGATION_DRAFT_NOTICE; + update(); + return false; + } + rememberPosition(); + if (detail === NAVIGATION_DRAFT_NOTICE) detail = ""; + return true; + }; + const views = new Map< + string, + { + model: DurableChatModel; + execution: ExecutionModel; + context: ContextModel; + position: ReadingPosition; + selection: [number, number]; + } + >(); + let pendingPosition: ReadingPosition | undefined; + let lastHistoryCursor: number | null = null; + let restoreSession: string | undefined; + const agents = createAgents(agentId, preserve, (id) => { + renderNavigation.navigate(id); + }); + const activate = (id: string) => { + if (id === agentId) return true; + connection.stop(); + views.set(agentId, { + model, + execution, + context, + position: element("conversation-scroll", HTMLDivElement).clientHeight + ? captureReadingPosition() + : ((model.sessionId + ? (readingPositions.get(`${agentId}:${model.sessionId}`) ?? + loadReadingPosition(agentId, model.sessionId)) + : undefined) ?? { id: null, offset: 0, bottom: true }), + selection: [input.selectionStart, input.selectionEnd], + }); + agentId = id; + publicSnapshot = undefined; + lastHistoryCursor = null; + restoreSession = undefined; + const saved = views.get(id); + model = saved?.model ?? new DurableChatModel(); + execution = saved?.execution ?? new ExecutionModel(); + context = saved?.context ?? new ContextModel(); + subscribing = false; + detail = ""; + drafts = browserDraftStore(() => window.localStorage, id); + recovery = new PendingRecovery(drafts, textPort, restore, (message) => { + detail = message; + }); + input.setSelectionRange( + ...(saved?.selection ?? [input.value.length, input.value.length]), + ); + pendingPosition = saved?.position; + search.connect(false, undefined); + element("search-dialog", HTMLDialogElement).close(); + agents.setCurrent(id); + void tasks.setOwner(id); + connection = makeConnection(); + update(); + if (pendingPosition && restoreReadingPosition(pendingPosition)) + pendingPosition = undefined; + connection.connect(); + return true; + }; + const renderNavigation = createNavigation( + settings, + (opener) => search.open(opener), + { + activate, + cancelSelection: () => agents.cancelNavigation(), + preserve, + changed: () => update(), + openTask: (id) => { + void tasks.open(id); + }, + closeTask: () => tasks.closeDetail(), + showTasks: () => { + void tasks.refresh(); + }, + }, + ); + + const presence = element("presence", HTMLDivElement); + const presenceLabel = element("presence-label", HTMLSpanElement); + const elapsed = element("presence-elapsed", HTMLSpanElement); + let elapsedTimer: ReturnType | undefined; + function renderPresence(): void { + if (elapsedTimer) clearTimeout(elapsedTimer); + const status = activityStatus({ + connected: model.connected, + running: model.running, + pending: model.pendingId !== undefined, + requestStartedAt: model.requestStartedAt( + execution.state?.cancelRequestId, + ), + control: execution.connected ? execution.state : undefined, + contextBusy: context.state?.busy === true, + }); + presence.hidden = !status; + presence.dataset["moving"] = String(status?.moving === true); + setText(presenceLabel, status?.label ?? ""); + const time = elapsedText(status?.startedAt, Date.now()); + setText(elapsed, time ? `· ${time}` : ""); + if (status?.moving && status.startedAt) + elapsedTimer = setTimeout(renderPresence, 1000); + } + + function restorePosition(): void { + if (!pendingPosition) return; + if (restoreReadingPosition(pendingPosition)) { + pendingPosition = undefined; + return; + } + if ( + model.connected && + model.hasEarlier && + model.beforeCursor !== null && + model.beforeCursor !== lastHistoryCursor && + model.sessionId + ) { + lastHistoryCursor = model.beforeCursor; + connection.command({ + type: "history", + sessionId: model.sessionId, + before: model.beforeCursor, + }); + } + } + + function markVisible(): void { + if ( + pendingPosition || + !publicSnapshot || + document.hidden || + model.hasNewer || + document.querySelector("dialog[open]") + ) + return; + const scroll = element("conversation-scroll", HTMLDivElement); + if ( + !scroll.clientHeight || + scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight >= 80 + ) + return; + const latest = publicSnapshot.messages.findLast( + (item) => item.role === "assistant" || item.role === "user", + ); + if (latest) + agents.markVisible(agentId, publicSnapshot.sessionId, latest.seq); + } + + function update(): void { + composerSizing.update(); + agents.updateDraft(agentId, textPort.value); + attachments.update(model.connected); + render(); + renderNavigation(model); + search.connect(model.connected, model.sessionId); + renderExecution(); + context.connected = model.connected; + context.running = model.running; + + element("welcome", HTMLElement).hidden = + model.messages.length > 0 || model.running; + send.disabled = + !model.connected || + attachments.blocked || + !model.sessionId || + model.pendingId !== undefined || + execution.state?.cancelling === true || + context.state?.busy === true || + textPort.value.length > 16000 || + textPort.value.trim().length === 0; + const state = element("connection-status", HTMLSpanElement); + setText(state, model.connected ? "연결됨" : "연결 대기"); + state.hidden = model.connected; + renderPresence(); + requestAnimationFrame(() => { + restorePosition(); + markVisible(); + }); + state.className = `connection-status${model.connected ? " online" : ""}`; + element("agent-dot", HTMLSpanElement).className = + `agent-dot${model.connected ? " online" : ""}`; + reconnect.hidden = model.connected; + const shownDetail = + textPort.value.length > 16000 + ? "메시지 길이 초과 · 최대 16,000자" + : detail; + notice.hidden = shownDetail.length === 0; + setText(notice, shownDetail); + earlier.hidden = !model.hasEarlier; + earlier.disabled = !model.connected; + latest.hidden = !model.hasNewer; + latest.disabled = !model.connected; + } + + const makeConnection = () => + new Connection( + socketUrl( + location.origin, + `/ws${agentId === "lina" ? "" : `?agent=${agentId}`}`, + ), + { + frame(frame) { + if (frame.type === "agent-status" && !subscribing) { + subscribing = true; + connection.command({ type: "subscribe", version: 2 }); + } + search.receive(frame); + const reading = captureReadingPosition(); + if ( + !pendingPosition && + model.messages.length && + !reading.bottom && + element("conversation-scroll", HTMLDivElement).clientHeight && + ["snapshot", "history", "entry-text"].includes(frame.type) + ) + pendingPosition = reading; + const previousSession = model.sessionId; + model.receive(frame); + if ( + frame.type === "snapshot" && + previousSession && + previousSession !== frame.snapshot.sessionId + ) + pendingPosition = undefined; + if ( + frame.type === "snapshot" && + restoreSession !== frame.snapshot.sessionId + ) { + restoreSession = frame.snapshot.sessionId; + pendingPosition = + pendingPosition ?? loadReadingPosition(agentId, restoreSession); + } + if (frame.type === "snapshot") { + publicSnapshot = frame.snapshot; + agents.updateSnapshot(frame.snapshot); + } + if (frame.type === "snapshot") context.bindSession(model.sessionId); + if (frame.type === "context-state" || frame.type === "context-error") + context.receive(frame); + if (frame.type === "snapshot") execution.bindSession(model.sessionId); + if (frame.type === "control-state" || frame.type === "control-error") + execution.receive(frame, model.sessionId); + if (frame.type === "snapshot") detail = ""; + if (frame.type === "control-state" && publicSnapshot) + agents.updateSnapshot( + publicSnapshot, + frame.state.approvals.filter((item) => item.state === "pending") + .length, + ); + recovery.receive(frame, model.sessionId); + update(); + if (pendingPosition && restoreReadingPosition(pendingPosition)) + pendingPosition = undefined; + }, + disconnected() { + subscribing = false; + model.disconnect(); + execution.disconnect(); + context.disconnect(); + recovery.disconnected(); + update(); + }, + notice(message) { + detail = message; + update(); + }, + }, + ); + + let connection = makeConnection(); + element("conversation-scroll", HTMLDivElement).addEventListener( + "scroll", + () => { + markVisible(); + if ( + !pendingPosition && + model.sessionId && + element("conversation-scroll", HTMLDivElement).clientHeight + ) + rememberPosition(); + }, + ); + document.addEventListener("visibilitychange", markVisible); + renderNavigation.start(); + const resizeViewport = () => { + document.documentElement.style.setProperty( + "--visible-height", + `${window.visualViewport?.height ?? window.innerHeight}px`, + ); + }; + window.visualViewport?.addEventListener("resize", resizeViewport); + resizeViewport(); + element("composer", HTMLFormElement).addEventListener("submit", (event) => { + event.preventDefault(); + const text = textPort.value.trim(); + if (send.disabled || !model.sessionId) return; + const request = recovery.prepare(text, model.sessionId); + if (!request) { + update(); + return; + } + const { id } = request; + if (!model.send(id, text)) return; + recovery.sent(request); + textPort.value = ""; + drafts.saveDraft(""); + if ( + !connection.send({ type: "chat", sessionId: model.sessionId, id, text }) + ) { + model.disconnect(); + textPort.value = text; + drafts.saveDraft(text); + } + update(); + input.focus(); + }); + input.addEventListener("input", () => { + recovery.edited(); + drafts.saveDraft(textPort.value); + update(); + }); + earlier.addEventListener("click", () => { + if (model.sessionId && model.beforeCursor !== null) + connection.command({ + type: "history", + sessionId: model.sessionId, + before: model.beforeCursor, + }); + }); + latest.addEventListener("click", () => { + pendingPosition = undefined; + lastHistoryCursor = null; + model.showLatest(); + connection.command({ type: "subscribe", version: 2 }); + const scroll = element("conversation-scroll", HTMLDivElement); + scroll.scrollTop = scroll.scrollHeight; + }); + input.addEventListener("keydown", (event) => { + if (!shouldSend(event, settings.preferences.sendKey)) return; + event.preventDefault(); + if (!send.disabled) element("composer", HTMLFormElement).requestSubmit(); + }); + for (const button of document.querySelectorAll( + "[data-prompt]", + )) { + button.addEventListener("click", () => + restore(button.getAttribute("data-prompt") ?? ""), + ); + } + reconnect.addEventListener("click", () => { + detail = ""; + connection.connect(); + }); + window.addEventListener("beforeunload", (event) => { + if (!preserve()) { + event.preventDefault(); + event.returnValue = ""; + } + }); + window.addEventListener("pagehide", () => { + connection.stop(); + if (elapsedTimer) clearTimeout(elapsedTimer); + tasks.close(); + }); + window.addEventListener("pageshow", (event) => { + if (event.persisted) connection.resume(); + }); + connection.connect(); + + const desktop = (window as Window & { linaDesktop?: DesktopPlatform }) + .linaDesktop; + if (desktop?.platform !== "desktop") + installPwa( + () => drafts, + () => textPort.value, + ); + else + element("install-heading", HTMLElement) + .closest("section") + ?.setAttribute("hidden", ""); + window.addEventListener("offline", () => { + connection.connect(); + update(); + }); + window.addEventListener("online", () => { + connection.connect(); + update(); + }); +} diff --git a/packages/lina-web/client/conversation-draft.ts b/packages/lina-ui/client/conversation-draft.ts similarity index 100% rename from packages/lina-web/client/conversation-draft.ts rename to packages/lina-ui/client/conversation-draft.ts diff --git a/packages/lina-web/client/conversation-editor.ts b/packages/lina-ui/client/conversation-editor.ts similarity index 100% rename from packages/lina-web/client/conversation-editor.ts rename to packages/lina-ui/client/conversation-editor.ts diff --git a/packages/lina-web/client/execution-view.ts b/packages/lina-ui/client/execution-view.ts similarity index 87% rename from packages/lina-web/client/execution-view.ts rename to packages/lina-ui/client/execution-view.ts index 19b8ed1..0392e86 100644 --- a/packages/lina-web/client/execution-view.ts +++ b/packages/lina-ui/client/execution-view.ts @@ -1,25 +1,35 @@ +import type { ExecutionModel } from "../../lina-client/src/execution-model.ts"; import type { ControlClient } from "../../lina-core/src/control-wire.ts"; -import type { ExecutionModel } from "./execution-model.ts"; import { element, setText } from "./render.ts"; import { approvalDescriptor, toolLabel } from "./tool-label.ts"; export function createExecutionView( - model: ExecutionModel, + source: ExecutionModel | (() => ExecutionModel), send: (frame: ControlClient) => boolean, changed: () => void, ) { + const getModel = () => (typeof source === "function" ? source() : source); + let bound = getModel(); const approvals = element("approval-list", HTMLDivElement); const stop = element("cancel-run", HTMLButtonElement); const error = element("execution-error", HTMLParagraphElement); const approvalNodes = new Map(); const dispatch = (frame: ControlClient | undefined) => { if (!frame) return; + const model = getModel(); model.sent(frame); if (!send(frame)) model.failed(); changed(); }; - stop.addEventListener("click", () => dispatch(model.cancelCommand())); + stop.addEventListener("click", () => dispatch(getModel().cancelCommand())); return () => { + const model = getModel(); + if (bound !== model) { + approvals.replaceChildren(); + approvalNodes.clear(); + bound = model; + error.hidden = true; + } const state = model.state; if (!state) { approvals.replaceChildren(); diff --git a/packages/lina-web/client/execution.css b/packages/lina-ui/client/execution.css similarity index 73% rename from packages/lina-web/client/execution.css rename to packages/lina-ui/client/execution.css index c691d54..7ffbd94 100644 --- a/packages/lina-web/client/execution.css +++ b/packages/lina-ui/client/execution.css @@ -1,10 +1,11 @@ .execution-bar { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 10px; - margin-bottom: 8px; - padding: 6px 2px; + margin-bottom: 4px; + min-height: 36px; + padding: 4px; font-size: 0.75rem; } .execution-error { @@ -14,11 +15,14 @@ margin: 7px 0; } .approval-request { - border-left: 2px solid var(--accent); - padding: 12px; - margin: 10px 0; - background: var(--surface-raised); - border-radius: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 16px; + border: 1px solid var(--line); + padding: 14px 16px; + margin: 0 0 8px; + background: var(--surface-sidebar); + border-radius: 16px; } .approval-request strong { font-size: 0.8rem; @@ -33,7 +37,8 @@ color: var(--text); } .approval-input { - margin-bottom: 10px; + margin-bottom: 0; + grid-column: 1 / -1; } .approval-input > summary { cursor: pointer; @@ -52,6 +57,10 @@ } .approval-buttons { display: flex; + grid-column: 2; + grid-row: 1 / 3; + align-items: center; + justify-content: flex-end; gap: 8px; } .approval-buttons button[data-decision="allow"] { @@ -60,21 +69,16 @@ border-color: var(--button-bg); } -.execution-bar { - min-height: 42px; - justify-content: flex-start; - margin-bottom: 6px; -} .presence-mark { - flex: 0 0 28px; - height: 28px; + flex: 0 0 20px; + height: 24px; display: grid; place-items: center; border-radius: 9px; - background: var(--selection); + background: transparent; color: var(--accent); font: - italic 23px Georgia, + italic 20px Georgia, serif; letter-spacing: -0.12em; padding-right: 4px; @@ -99,11 +103,12 @@ #cancel-run, .approval-buttons button { border: 0; - border-radius: 6px; - background: var(--surface-raised); + border-radius: var(--control-radius); + background: transparent; color: var(--text); - padding: 8px 12px; - min-height: 36px; + padding: 6px 12px; + min-height: var(--control-size); + font-size: var(--font-caption); flex-shrink: 0; } .approval-buttons button:disabled { @@ -138,3 +143,11 @@ max-height: 120px; } } + +#cancel-run:hover, +.approval-buttons button:hover { + background: var(--hover); +} +.approval-buttons button[data-decision="allow"]:hover { + background: var(--button-hover); +} diff --git a/packages/lina-web/client/favicon.svg b/packages/lina-ui/client/favicon.svg similarity index 100% rename from packages/lina-web/client/favicon.svg rename to packages/lina-ui/client/favicon.svg diff --git a/packages/lina-web/client/hub-settings.css b/packages/lina-ui/client/hub-settings.css similarity index 87% rename from packages/lina-web/client/hub-settings.css rename to packages/lina-ui/client/hub-settings.css index 8e90f25..69ccc2b 100644 --- a/packages/lina-web/client/hub-settings.css +++ b/packages/lina-ui/client/hub-settings.css @@ -1,11 +1,12 @@ .hub-card { display: grid; gap: 12px; - padding: 4px 0 8px; + padding: 0; } .hub-actions { display: flex; flex-wrap: wrap; + align-items: center; gap: 8px; justify-content: flex-end; } @@ -23,7 +24,7 @@ } #hub-summary { margin: 4px 0 0; - font-size: 0.78rem; + font-size: 0.8125rem; line-height: 1.7; color: var(--muted); overflow-wrap: anywhere; @@ -43,4 +44,7 @@ .hub-card .setting-row { align-items: flex-start; } + .hub-actions { + width: 100%; + } } diff --git a/packages/lina-web/client/hub-settings.ts b/packages/lina-ui/client/hub-settings.ts similarity index 100% rename from packages/lina-web/client/hub-settings.ts rename to packages/lina-ui/client/hub-settings.ts diff --git a/packages/lina-web/client/icon-192.png b/packages/lina-ui/client/icon-192.png similarity index 100% rename from packages/lina-web/client/icon-192.png rename to packages/lina-ui/client/icon-192.png diff --git a/packages/lina-web/client/icon-512.png b/packages/lina-ui/client/icon-512.png similarity index 100% rename from packages/lina-web/client/icon-512.png rename to packages/lina-ui/client/icon-512.png diff --git a/packages/lina-web/client/index.html b/packages/lina-ui/client/index.html similarity index 51% rename from packages/lina-web/client/index.html rename to packages/lina-ui/client/index.html index e69ae66..ceeda20 100644 --- a/packages/lina-web/client/index.html +++ b/packages/lina-ui/client/index.html @@ -4,7 +4,7 @@ - + Lina · 나의 에이전트 @@ -17,13 +17,13 @@ -
-

Lina

연결 중
+
+

Lina

연결 중
@@ -54,30 +54,30 @@
-
Enter로 보내기 · Shift + Enter로 줄바꿈
+
Enter로 보내기 · Shift + Enter로 줄바꿈
- +

설정

Lina를 나에게 맞게

-
-

편안하게, 내 방식으로

이 기기의 화면과 입력 방식을 조정하세요.

화면

화면 모드
글자 크기
+
+

편안하게, 내 방식으로

이 기기의 화면과 입력 방식을 조정하세요.

화면

화면 모드
글자 크기

입력

메시지 보내기

설치 및 업데이트

Lina 웹앱 설치

홈 화면이나 Dock에 추가해, 브라우저 탭 없이 별도 창으로 열 수 있습니다. 설치 후에도 같은 Lina 서버에 연결해 기존 대화를 이어갑니다.

설치 방법

브라우저 메뉴에서 앱 설치를 선택하세요. iPhone·iPad에서는 공유 메뉴의 홈 화면에 추가를 선택하세요.

웹앱 업데이트

이 기기의 Lina 화면을 최신 버전으로 바꿉니다. 새 버전을 확인한 뒤 적용하면, 작성 중인 내용을 저장하고 화면을 새로고침합니다. 대화 기록은 유지됩니다.

업데이트

- +
-

작업

+

작업

키보드 단축키

대화 검색
⌘ K
설정
⌘ ,
줄바꿈
Shift + Enter
닫기
Esc

대화 검색

- -

마음 상태

-

에이전트 설정

캐릭터 프로필과 외형

필요할 때만 대화에서 참고하는 설정입니다.

현재 기분과 함께 쌓은 변화

-

대화 방식

Lina가 어떤 식으로 대화하면 좋은지 알려주세요. 나중에 다시 바꿀 수 있어요.

대화 예시

배운 대화 선호

-

에이전트 추가

+ +

마음 상태

+

에이전트 설정

캐릭터 프로필과 외형

필요할 때만 대화에서 참고하는 설정입니다.

현재 기분과 함께 쌓은 변화

+

대화 방식

Lina가 어떤 식으로 대화하면 좋은지 알려주세요. 나중에 다시 바꿀 수 있어요.

대화 예시

배운 대화 선호

+

에이전트 추가

@@ -87,7 +87,7 @@

나를 알려주세요

초안 - +
@@ -95,7 +95,7 @@

나를 알려주세요

- +
diff --git a/packages/lina-web/client/intro-api.ts b/packages/lina-ui/client/intro-api.ts similarity index 100% rename from packages/lina-web/client/intro-api.ts rename to packages/lina-ui/client/intro-api.ts diff --git a/packages/lina-web/client/intro-app.ts b/packages/lina-ui/client/intro-app.ts similarity index 99% rename from packages/lina-web/client/intro-app.ts rename to packages/lina-ui/client/intro-app.ts index 98e3247..9f56ff0 100644 --- a/packages/lina-web/client/intro-app.ts +++ b/packages/lina-ui/client/intro-app.ts @@ -1,3 +1,4 @@ +import { shouldSend } from "../../lina-client/src/preferences.ts"; import { createComposerSizing } from "./composer-size.ts"; import { introRequest, isOnboardingAbort } from "./intro-api.ts"; import { checkIntroConnection } from "./intro-connection.ts"; @@ -29,7 +30,6 @@ import { renderTranscript, } from "./intro-view.ts"; import { createChatAgent } from "./onboarding-navigation.ts"; -import { shouldSend } from "./preferences.ts"; import { element, setText } from "./render.ts"; import { createSettings } from "./settings.ts"; diff --git a/packages/lina-web/client/intro-connection.ts b/packages/lina-ui/client/intro-connection.ts similarity index 100% rename from packages/lina-web/client/intro-connection.ts rename to packages/lina-ui/client/intro-connection.ts diff --git a/packages/lina-web/client/intro-model.ts b/packages/lina-ui/client/intro-model.ts similarity index 100% rename from packages/lina-web/client/intro-model.ts rename to packages/lina-ui/client/intro-model.ts diff --git a/packages/lina-web/client/intro-types.ts b/packages/lina-ui/client/intro-types.ts similarity index 100% rename from packages/lina-web/client/intro-types.ts rename to packages/lina-ui/client/intro-types.ts diff --git a/packages/lina-web/client/intro-view.ts b/packages/lina-ui/client/intro-view.ts similarity index 100% rename from packages/lina-web/client/intro-view.ts rename to packages/lina-ui/client/intro-view.ts diff --git a/packages/lina-web/client/intro.css b/packages/lina-ui/client/intro.css similarity index 72% rename from packages/lina-web/client/intro.css rename to packages/lina-ui/client/intro.css index 8395caf..0193a4e 100644 --- a/packages/lina-web/client/intro.css +++ b/packages/lina-ui/client/intro.css @@ -18,14 +18,11 @@ body:is(.intro-active, .boot-pending) .workspace { .intro-toolbar, .intro-panel, .intro-changes { - width: min(100%, 760px); + width: min(100%, var(--conversation-width)); margin: 0 auto; padding: 16px 24px; box-sizing: border-box; } -.intro-toolbar { - border-bottom: 1px solid var(--line); -} .intro-setup { margin-top: 16px; } @@ -34,7 +31,7 @@ body:is(.intro-active, .boot-pending) .workspace { } .intro-setup button { margin: 8px 8px 0 0; - min-height: 44px; + min-height: 36px; } .intro-kicker { font-size: 13px; @@ -42,7 +39,7 @@ body:is(.intro-active, .boot-pending) .workspace { margin: 0 0 12px; } .intro-progress { - font-size: 12px; + font-size: 13px; color: var(--muted); line-height: 1.7; margin: 12px 0; @@ -53,15 +50,23 @@ body:is(.intro-active, .boot-pending) .workspace { flex-wrap: wrap; } .intro-modes button { - border: 1px solid var(--line-strong); - border-radius: 8px; + border: 0; + border-radius: var(--control-radius); padding: 8px 12px; + min-height: 36px; + line-height: 20px; + font-size: 0.875rem; + background: transparent; + color: var(--muted); } .intro-modes button[aria-pressed="true"] { - border-color: var(--accent); background: var(--selection); color: var(--text); } +.intro-modes button:hover:not(:disabled) { + background: var(--surface-raised); + color: var(--text); +} .intro-summary { margin-top: 16px; font-size: 13px; @@ -100,8 +105,10 @@ body:is(.intro-active, .boot-pending) .workspace { } .intro-lead { - font-size: 17px; + font-size: 16px; + font-weight: 500; margin: 12px 0; + text-wrap: balance; } .intro-share { display: flex; @@ -111,10 +118,17 @@ body:is(.intro-active, .boot-pending) .workspace { font-size: 13px; color: var(--muted); } +.intro-share input { + width: 16px; + height: 16px; + flex-shrink: 0; + margin: 0; + accent-color: var(--accent); +} .intro-choices { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; + grid-template-columns: minmax(0, 1fr); + gap: 4px; } .intro-later { color: var(--muted); @@ -123,10 +137,10 @@ body:is(.intro-active, .boot-pending) .workspace { word-break: keep-all; } .template-dialog { - max-width: 620px; + width: min(620px, calc(100vw - 24px)); } .template-content { - padding: 24px; + padding: 8px 24px 24px; overflow-y: auto; } .template-description { @@ -136,33 +150,38 @@ body:is(.intro-active, .boot-pending) .workspace { } .template-list { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; + grid-template-columns: minmax(0, 1fr); + gap: 4px; margin-top: 16px; } .template-choice { display: flex; flex-direction: column; - gap: 8px; - padding: 16px; - min-height: 76px; + gap: 4px; + padding: 12px; + min-height: 60px; text-align: left; color: var(--text); - background: var(--surface-raised); - border: 1px solid var(--line-strong); - border-radius: 10px; + background: transparent; + border: 0; + border-radius: var(--control-radius); + font-size: 0.875rem; + line-height: 1.6; cursor: pointer; } .template-choice span { color: var(--muted); font-size: 13px; + word-break: keep-all; + overflow-wrap: anywhere; + text-wrap: balance; } .template-choice strong { word-break: keep-all; overflow-wrap: anywhere; } -.template-choice:hover { - border-color: var(--accent); +.template-choice:hover:not(:disabled) { + background: var(--selection); } .template-choice:disabled { opacity: 0.6; @@ -171,24 +190,39 @@ body:is(.intro-active, .boot-pending) .workspace { .intro-choice { display: flex; flex-direction: column; - gap: 8px; + gap: 4px; text-align: left; min-width: 0; - padding: 16px; - border: 1px solid var(--line-strong); - border-radius: 10px; - background: var(--surface-raised); + padding: 12px; + border: 0; + border-radius: var(--control-radius); + background: transparent; color: var(--text); + font-size: 0.875rem; + line-height: 1.6; cursor: pointer; } -.intro-choice:hover { - border-color: var(--accent); +.intro-choice:hover:not(:disabled) { + background: var(--selection); +} +.intro-choice:disabled { + opacity: 0.55; +} +.intro-choice strong, +.template-choice strong { + font-weight: 500; +} +.intro-choice strong { + word-break: keep-all; + overflow-wrap: anywhere; } .intro-choice small { color: var(--muted); line-height: 1.5; word-break: keep-all; overflow-wrap: anywhere; + font-size: 13px; + text-wrap: balance; } .intro-legacy { margin: 14px 0; @@ -226,6 +260,11 @@ body:is(.intro-active, .boot-pending) .workspace { width: 100%; } @media (max-width: 767px) { + .intro-setup button, + .intro-modes button, + .intro-share { + min-height: 44px; + } .intro-toolbar > a, .intro-panel > a { display: inline-flex; @@ -242,13 +281,11 @@ body:is(.intro-active, .boot-pending) .workspace { } .intro-toolbar, .intro-panel, - .intro-changes { + .intro-changes, + .template-content { padding-left: 16px; padding-right: 16px; } - .intro-choices { - grid-template-columns: 1fr; - } .intro-review { grid-template-columns: 1fr; gap: 4px; @@ -256,9 +293,6 @@ body:is(.intro-active, .boot-pending) .workspace { .intro-review dd { margin-bottom: 12px; } - .intro-entry-link { - font-size: 11px; - } .intro-active .connection-status { display: none; } diff --git a/packages/lina-web/client/markdown-inline.ts b/packages/lina-ui/client/markdown-inline.ts similarity index 100% rename from packages/lina-web/client/markdown-inline.ts rename to packages/lina-ui/client/markdown-inline.ts diff --git a/packages/lina-web/client/markdown-parser.ts b/packages/lina-ui/client/markdown-parser.ts similarity index 100% rename from packages/lina-web/client/markdown-parser.ts rename to packages/lina-ui/client/markdown-parser.ts diff --git a/packages/lina-web/client/markdown-types.ts b/packages/lina-ui/client/markdown-types.ts similarity index 100% rename from packages/lina-web/client/markdown-types.ts rename to packages/lina-ui/client/markdown-types.ts diff --git a/packages/lina-web/client/markdown.css b/packages/lina-ui/client/markdown.css similarity index 100% rename from packages/lina-web/client/markdown.css rename to packages/lina-ui/client/markdown.css diff --git a/packages/lina-web/client/markdown.ts b/packages/lina-ui/client/markdown.ts similarity index 100% rename from packages/lina-web/client/markdown.ts rename to packages/lina-ui/client/markdown.ts diff --git a/packages/lina-web/client/mind-view.ts b/packages/lina-ui/client/mind-view.ts similarity index 100% rename from packages/lina-web/client/mind-view.ts rename to packages/lina-ui/client/mind-view.ts diff --git a/packages/lina-ui/client/model-combobox-types.ts b/packages/lina-ui/client/model-combobox-types.ts new file mode 100644 index 0000000..053bdc9 --- /dev/null +++ b/packages/lina-ui/client/model-combobox-types.ts @@ -0,0 +1,26 @@ +import type { SettingsDocument, SettingsNode } from "./settings-dom.ts"; + +export interface ModelChoice { + value: string; + label: string; + search?: string; + inherited?: boolean; +} +export interface ModelComboboxAdapter { + root: SettingsNode; + input: SettingsNode; + close(): void; + set( + items: ModelChoice[], + value: string, + label: string, + disabled?: boolean, + ): void; + disable(disabled: boolean): void; +} +export type ModelComboboxFactory = ( + doc: SettingsDocument, + id: string, + label: string, + change: (value: string) => void, +) => ModelComboboxAdapter; diff --git a/packages/lina-ui/client/model-combobox.ts b/packages/lina-ui/client/model-combobox.ts new file mode 100644 index 0000000..49b9d59 --- /dev/null +++ b/packages/lina-ui/client/model-combobox.ts @@ -0,0 +1,40 @@ +import { createElement, createRef } from "react"; +import { flushSync } from "react-dom"; +import { + ModelCombobox, + type ModelComboboxHandle, +} from "./components/model-combobox.tsx"; +import { mountView } from "./components/mount.ts"; +import type { ModelComboboxAdapter } from "./model-combobox-types.ts"; +import type { SettingsDocument, SettingsNode } from "./settings-dom.ts"; + +/** Browser boundary: controller tests inject a factory instead of emulating React's DOM. */ +export function createModelCombobox( + doc: SettingsDocument, + id: string, + label: string, + change: (value: string) => void, +): ModelComboboxAdapter { + const root = doc.createElement("div"); + root.className = "model-combobox"; + const ref = createRef(); + // SettingsDocument is structural; this production adapter requires the real DOM. + const view = mountView(root as unknown as HTMLElement); + view.render( + createElement(ModelCombobox, { id, label, onChange: change, ref }), + ); + const handle = () => { + if (!ref.current) throw Error("Model combobox is not mounted"); + return ref.current; + }; + const input = handle().input; + if (!input) throw Error("Model combobox input is not mounted"); + return { + root, + input: input as unknown as SettingsNode, + close: () => flushSync(() => handle().close()), + set: (items, value, label, disabled = false) => + flushSync(() => handle().set({ items, value, label, disabled })), + disable: (disabled) => flushSync(() => handle().disable(disabled)), + }; +} diff --git a/packages/lina-web/client/model-settings.ts b/packages/lina-ui/client/model-settings.ts similarity index 89% rename from packages/lina-web/client/model-settings.ts rename to packages/lina-ui/client/model-settings.ts index 61af7c5..1159883 100644 --- a/packages/lina-web/client/model-settings.ts +++ b/packages/lina-ui/client/model-settings.ts @@ -11,51 +11,17 @@ import type { } from "../../lina-runtime/src/models/types.ts"; import { MODEL_ROLES } from "../../lina-runtime/src/models/types.ts"; import { agentRequest, type RequestFn } from "./agent-editor.ts"; -import { createModelCombobox, type ModelChoice } from "./model-combobox.ts"; +import type { + ModelChoice, + ModelComboboxFactory, +} from "./model-combobox-types.ts"; +import type { SettingsDocument, SettingsNode } from "./settings-dom.ts"; + +// Existing task and hub controllers consume these structural DOM contracts. +export type { SettingsDocument, SettingsNode } from "./settings-dom.ts"; export { MODEL_ROLES }; -/** The browser DOM and the structural test fixture share this small surface. */ -export interface SettingsNode { - id: string; - className: string; - type: string; - min: string; - max: string; - step: string; - value: string; - checked?: boolean; - disabled: boolean; - textContent: string | null; - append(...nodes: SettingsNode[]): void; - replaceChildren(...nodes: SettingsNode[]): void; - before(...nodes: SettingsNode[]): void; - after(...nodes: SettingsNode[]): void; - querySelectorAll(selector: string): Iterable; - hidden: boolean; - setAttribute(name: string, value: string): void; - removeAttribute(name: string): void; - contains(node: unknown): boolean; - select?(): void; - scrollIntoView?(options?: { block: "nearest" }): void; - addEventListener( - type: string, - listener: (event: { - key?: string; - relatedTarget?: unknown; - preventDefault(): void; - stopPropagation(): void; - }) => unknown, - ): void; - checkValidity(): boolean; - reportValidity(): boolean; - focus(): void; -} -export interface SettingsDocument { - location: { href: string }; - getElementById(id: string): SettingsNode | null; - createElement(tag: string): SettingsNode; -} function browserDocument(): SettingsDocument { const doc = (globalThis as { document?: SettingsDocument }).document; if (!doc) throw Error("Model settings require a document"); @@ -82,6 +48,7 @@ function profileLabel(p: ModelProfile, catalog: CatalogModel[]) { export function installModelSettings( dialog: { open: boolean }, + combobox: ModelComboboxFactory, doc: SettingsDocument = browserDocument(), request: RequestFn = agentRequest, ) { @@ -109,7 +76,7 @@ export function installModelSettings( loading = false, testing = false; let trialId = ""; - const trialCombo = createModelCombobox( + const trialCombo = combobox( doc, "model-trial", "연결 테스트 모델", @@ -155,7 +122,7 @@ export function installModelSettings( const rows = new Map< RowRole, { - combo: ReturnType; + combo: ReturnType; reason: SettingsNode; hint: SettingsNode; } @@ -246,43 +213,38 @@ export function installModelSettings( const title = doc.createElement("label"); title.textContent = label; title.setAttribute("for", `model-${role}-input`); - const combo = createModelCombobox( - doc, - `model-${role}`, - `${label} 모델`, - (value) => { - if (loading || !model.draft) return; - if (!value) { - const draft = model.draft; - const bound = - role === "default" - ? draft.defaultProfileId - : scope.value - ? draft.agentRoles[scope.value]?.[role] - : draft.roles[role]; - if (!bound) return; - model.edit((s) => bind(s, role, "")); - changed(); - return; - } - const item = model.snapshot?.catalog.find( - (m) => - m.authenticated && keyOf(m) === value && modelSupportsRole(m, role), - ); - if (!item) return; - model.edit((s) => { - const previous = getProfile(s, role); - const p = ensureProfile( - s, - item, - role === "default" ? (previous?.reasoning ?? "low") : "low", - previous, - ); - bind(s, role, p.id); - }); + const combo = combobox(doc, `model-${role}`, `${label} 모델`, (value) => { + if (loading || !model.draft) return; + if (!value) { + const draft = model.draft; + const bound = + role === "default" + ? draft.defaultProfileId + : scope.value + ? draft.agentRoles[scope.value]?.[role] + : draft.roles[role]; + if (!bound) return; + model.edit((s) => bind(s, role, "")); changed(); - }, - ); + return; + } + const item = model.snapshot?.catalog.find( + (m) => + m.authenticated && keyOf(m) === value && modelSupportsRole(m, role), + ); + if (!item) return; + model.edit((s) => { + const previous = getProfile(s, role); + const p = ensureProfile( + s, + item, + role === "default" ? (previous?.reasoning ?? "low") : "low", + previous, + ); + bind(s, role, p.id); + }); + changed(); + }); const reason = doc.createElement("select"); reason.id = `model-${role}-reasoning`; reason.setAttribute("aria-label", `${label} 추론 수준`); diff --git a/packages/lina-ui/client/navigation.css b/packages/lina-ui/client/navigation.css new file mode 100644 index 0000000..11540f1 --- /dev/null +++ b/packages/lina-ui/client/navigation.css @@ -0,0 +1,228 @@ +.workspace { + grid-template-rows: minmax(0, 1fr); +} +.sidebar { + min-height: 0; +} +.list-switch { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 8px 12px; + border-bottom: 1px solid var(--line); +} +.list-switch button { + min-height: var(--control-size); + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); +} +.list-switch button[aria-pressed="true"] { + color: var(--text); + background: var(--selection); +} +/* Layout controls belong to the task list, with visible action names. */ +.task-list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + margin: 4px 8px 8px; +} +.task-list-toolbar #task-reload { + margin: 0 0 0 auto; + padding: 6px 8px; +} +.task-list-toolbar .ui-button { + font-weight: 400; + color: var(--muted); +} +.task-list-toolbar .ui-button:hover, +.task-list-toolbar .ui-button[aria-pressed="true"] { + color: var(--text); +} +.task-column { + position: relative; + width: 280px; + min-width: 0; + background: var(--surface-sidebar); + border-right: 1px solid var(--line); + overflow-y: auto; +} +.task-column .tasks-heading { + min-height: 56px; + padding: 8px 12px; +} +.task-column .tasks-heading .section-label { + font-size: var(--font-ui); + font-weight: 500; + color: var(--text); +} +.mobile-navigation { + display: none; +} +html[data-layout="dual"] .workspace { + grid-template-columns: var(--sidebar-width) 280px minmax(0, 1fr); +} +html[data-layout="single"] #task-pane { + flex: 1; +} +#task-pane { + min-width: 0; +} +.sidebar-content { + min-height: 0; +} +[hidden] { + display: none !important; +} +@media (min-width: 768px) { + #close-sidebar { + display: none; + } + html[data-sidebar-collapsed="true"] .list-switch { + flex-direction: column; + padding: 4px; + } + html[data-sidebar-collapsed="true"] .sidebar .task-list-toolbar { + display: none; + } + html[data-sidebar-collapsed="true"] .agent-filter-field { + display: none; + } +} +@media (max-width: 767px) { + .workspace { + display: block; + height: var(--visible-height, 100dvh); + padding-bottom: calc(60px + env(safe-area-inset-bottom)); + } + .sidebar, + html[data-sidebar-collapsed="true"] .sidebar { + display: flex; + width: 100%; + height: 100%; + border: 0; + } + .sidebar .sidebar-label, + html[data-sidebar-collapsed="true"] .sidebar-label { + display: initial; + } + .sidebar-content { + overflow-y: auto; + flex: 1; + } + .sidebar .account-area, + #collapse-sidebar, + #close-sidebar, + #sidebar-resizer, + .sidebar-search, + .list-switch { + display: none; + } + .sidebar-top { + min-height: 56px; + padding-top: env(safe-area-inset-top); + } + .main { + height: 100%; + } + html[data-screen="agents"] .main, + html[data-screen="tasks"] .main, + html[data-screen="settings"] .main { + display: none; + } + html[data-screen="conversation"] .sidebar { + display: none; + } + html[data-screen="conversation"] .workspace { + padding-bottom: 0; + } + #open-sidebar { + display: inline-flex; + } + .mobile-navigation { + position: fixed; + bottom: 0; + inset-inline: 0; + display: flex; + padding: 4px 8px calc(4px + env(safe-area-inset-bottom)); + min-height: 60px; + background: var(--surface-sidebar); + border-top: 1px solid var(--line); + } + .mobile-navigation button { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 52px; + border: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: var(--font-caption); + border-radius: var(--control-radius); + } + .mobile-navigation button[aria-pressed="true"] { + color: var(--text); + background: transparent; + font-weight: 600; + } + html[data-screen="conversation"] .mobile-navigation, + .intro-active .mobile-navigation, + .boot-pending .mobile-navigation { + display: none; + } + .settings-dialog, + .task-dialog, + .search-dialog { + width: 100%; + max-width: 100%; + height: var(--visible-height, 100dvh); + max-height: var(--visible-height, 100dvh); + margin: 0; + border-radius: 0; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0s !important; + transition-duration: 0s !important; + } +} + +.intro-active #composer-model { + display: none; +} +@media (max-width: 767px) { + .intro-active .workspace { + padding-bottom: 0; + } +} +html[data-layout="dual"][data-sidebar-collapsed="true"] .workspace { + grid-template-columns: 76px 280px minmax(0, 1fr); +} + +#mobile-search { + display: none; +} +@media (max-width: 767px) { + #mobile-search { + display: inline-grid; + margin-left: auto; + } + #current-agent-settings { + margin-left: 0; + } +} +.intro-active #mobile-search { + display: none; +} diff --git a/packages/lina-ui/client/navigation.ts b/packages/lina-ui/client/navigation.ts new file mode 100644 index 0000000..4cc2af8 --- /dev/null +++ b/packages/lina-ui/client/navigation.ts @@ -0,0 +1,308 @@ +import { createElement } from "react"; +import type { ChatModel } from "../../lina-client/src/model.ts"; +import { + type NavigationState, + navigationLayout, + navigationUrl, + parseNavigation, + type Screen, +} from "../../lina-client/src/navigation-state.ts"; +import { createActionMenu } from "./components/action-menu.tsx"; +import { KeyboardIcon, SettingsIcon } from "./components/icons.tsx"; +import { mountView } from "./components/mount.ts"; +import { TaskLayoutControl } from "./components/task-layout-control.tsx"; +import { element, setText } from "./render.ts"; +import type { createSettings } from "./settings.ts"; +import { installSidebarResize } from "./sidebar-resize.ts"; + +export function createNavigation( + settings: ReturnType, + openSearch: (opener: HTMLElement) => void, + options: { + activate: (agentId: string) => boolean; + preserve: () => boolean; + showTasks: () => void; + openTask: (id: string) => void; + closeTask: () => void; + changed: () => void; + cancelSelection: () => void; + }, +) { + installSidebarResize(settings); + const sidebar = element("sidebar", HTMLElement), + workspace = sidebar.parentElement; + if (!workspace) throw Error("Missing workspace"); + const open = element("open-sidebar", HTMLButtonElement), + collapse = element("collapse-sidebar", HTMLButtonElement); + const account = element("account-button", HTMLButtonElement), + search = element("open-search", HTMLButtonElement), + shortcuts = element("shortcuts-dialog", HTMLDialogElement); + const mobile = matchMedia("(max-width: 767px)"), + mac = /Mac|iPhone|iPad/.test(navigator.platform), + modifier = mac ? "⌘" : "Ctrl"; + setText(element("search-shortcut", HTMLElement), `${modifier} K`); + setText(element("modifier-send-label", HTMLElement), `${modifier} + Enter`); + for (const label of shortcuts.querySelectorAll( + "[data-shortcut]", + )) + setText(label, `${modifier} ${label.dataset["shortcut"]?.toUpperCase()}`); + const closeMenu = () => accountMenu.close(); + let state = parseNavigation(location.href); + let previousScreen: Screen = + state.screen === "settings" ? "agents" : state.screen; + let extraTasks = false; + try { + extraTasks = localStorage.getItem("lina.tasks-column.v1") === "true"; + } catch { + /* Storage is optional. */ + } + const agentPane = element("agent-pane", HTMLElement); + const taskPane = element("task-pane", HTMLElement); + const column = element("task-column", HTMLElement); + const layoutHost = element("task-layout-control", HTMLElement); + const layoutView = mountView(layoutHost); + const content = element("sidebar-content", HTMLDivElement); + const root = document.documentElement; + const apply = () => { + const available = + navigationLayout( + window.innerWidth, + true, + sidebar.getBoundingClientRect().width, + ) === "dual"; + const layout = navigationLayout( + window.innerWidth, + extraTasks, + sidebar.getBoundingClientRect().width, + ); + root.dataset["layout"] = layout; + root.dataset["screen"] = state.screen; + root.dataset["list"] = state.screen === "tasks" ? "tasks" : "agents"; + column.hidden = layout !== "dual"; + const parent = layout === "dual" ? column : content; + if (taskPane.parentElement !== parent) parent.append(taskPane); + agentPane.hidden = layout !== "dual" && state.screen === "tasks"; + taskPane.hidden = layout !== "dual" && state.screen !== "tasks"; + for (const button of document.querySelectorAll( + "[data-screen]", + )) + button.setAttribute( + "aria-pressed", + String( + button.dataset["screen"] === + (state.screen === "conversation" ? "agents" : state.screen), + ), + ); + layoutHost.hidden = !available; + layoutView.render( + createElement(TaskLayoutControl, { + active: layout === "dual", + onToggle: toggleTasks, + }), + ); + }; + function toggleTasks() { + extraTasks = !extraTasks; + try { + localStorage.setItem("lina.tasks-column.v1", String(extraTasks)); + } catch { + /* The current view still works when storage is unavailable. */ + } + apply(); + if (extraTasks) options.showTasks(); + // Moving the pane must not lose keyboard focus. If the task pane closes, + // return to the selected list tab rather than an invisible control. + const target = taskPane.hidden + ? document.querySelector( + '.list-switch [aria-pressed="true"]', + ) + : layoutHost.querySelector("button"); + target?.focus({ preventScroll: true }); + } + const go = (next: NavigationState, replace = false) => { + options.cancelSelection(); + if (!options.preserve() || !options.activate(next.agentId)) { + history.replaceState(state, "", navigationUrl(state)); + return false; + } + closeMenu(); + if (next.screen !== "settings") previousScreen = next.screen; + const taskChanged = state.taskId !== next.taskId; + state = next; + if (next.screen !== "settings") + element("settings-dialog", HTMLDialogElement).close(); + history[replace ? "replaceState" : "pushState"]( + state, + "", + navigationUrl(state), + ); + apply(); + options.changed(); + if (taskChanged) { + if (state.taskId) options.openTask(state.taskId); + else options.closeTask(); + } + if (state.screen === "tasks") options.showTasks(); + if (state.screen === "settings") + settings.open(element("mobile-settings", HTMLButtonElement)); + return true; + }; + const closeDrawer = () => closeMenu(); + const opener = () => (mobile.matches ? open : account); + const prepare = () => { + options.cancelSelection(); + const target = opener(); + closeMenu(); + closeDrawer(); + return target; + }; + const openSettings = () => { + const target = prepare(); + shortcuts.close(); + settings.open(target); + }; + const showSearch = () => { + const target = mobile.matches + ? element("mobile-search", HTMLButtonElement) + : search; + prepare(); + shortcuts.close(); + element("settings-dialog", HTMLDialogElement).close(); + openSearch(target); + }; + const accountMenu = createActionMenu({ + id: "account-menu", + label: "내 설정 메뉴", + side: "top", + items: [ + { + id: "open-settings", + label: "설정", + icon: createElement(SettingsIcon), + shortcut: `${modifier} ,`, + run: openSettings, + }, + { + id: "open-shortcuts", + label: "키보드 단축키", + icon: createElement(KeyboardIcon), + run: () => { + const target = prepare(); + shortcuts.showModal(); + shortcuts.addEventListener("close", () => target.focus(), { + once: true, + }); + }, + }, + ], + }); + accountMenu.bindTrigger(account); + for (const button of shortcuts.querySelectorAll("[data-close]")) + button.addEventListener("click", () => shortcuts.close()); + shortcuts.addEventListener("click", (event) => { + if (event.target === shortcuts) { + const r = shortcuts.getBoundingClientRect(); + if ( + event.clientX < r.left || + event.clientX > r.right || + event.clientY < r.top || + event.clientY > r.bottom + ) + shortcuts.close(); + } + }); + search.addEventListener("click", showSearch); + element("mobile-search", HTMLButtonElement).addEventListener( + "click", + showSearch, + ); + open.addEventListener("click", () => { + if (go({ agentId: state.agentId, screen: "agents" })) + element("agent-filter", HTMLInputElement).blur(); + }); + for (const button of document.querySelectorAll( + "[data-screen]", + )) + button.addEventListener("click", () => + go({ + agentId: state.agentId, + screen: button.dataset["screen"] as Screen, + }), + ); + window.addEventListener("popstate", () => + go(parseNavigation(location.href), true), + ); + window.addEventListener("resize", apply); + new ResizeObserver(apply).observe(sidebar); + element("settings-dialog", HTMLDialogElement).addEventListener( + "close", + () => { + if (state.screen === "settings") + go({ ...state, screen: previousScreen }, true); + }, + ); + history.replaceState(state, "", navigationUrl(state)); + apply(); + const updateCollapse = () => { + collapse.setAttribute( + "aria-expanded", + String(!settings.preferences.sidebarCollapsed), + ); + collapse.setAttribute( + "aria-label", + settings.preferences.sidebarCollapsed + ? "사이드바 펼치기" + : "사이드바 접기", + ); + collapse.title = collapse.getAttribute("aria-label") ?? ""; + }; + collapse.addEventListener("click", () => { + settings.update({ + sidebarCollapsed: !settings.preferences.sidebarCollapsed, + }); + updateCollapse(); + }); + updateCollapse(); + document.addEventListener("keydown", (event) => { + if (event.isComposing || event.altKey || !(event.metaKey || event.ctrlKey)) + return; + if (event.key.toLowerCase() === "k" || event.code === "KeyK") { + event.preventDefault(); + showSearch(); + } + if (event.key === ",") { + event.preventDefault(); + element("search-dialog", HTMLDialogElement).close(); + openSettings(); + } + }); + const render = (_model: ChatModel) => {}; + return Object.assign(render, { + navigate(agentId: string) { + return go({ agentId, screen: "conversation" }); + }, + taskOpened(id: string) { + options.cancelSelection(); + if (state.taskId === id) return; + state = { agentId: state.agentId, screen: "tasks", taskId: id }; + history.pushState(state, "", navigationUrl(state)); + apply(); + }, + taskClosed() { + if (state.taskId) { + state = { agentId: state.agentId, screen: "tasks" }; + history.replaceState(state, "", navigationUrl(state)); + } + apply(); + }, + get state() { + return state; + }, + start() { + if (state.taskId) options.openTask(state.taskId); + if (state.screen === "tasks") options.showTasks(); + if (state.screen === "settings") + settings.open(element("mobile-settings", HTMLButtonElement)); + }, + }); +} diff --git a/packages/lina-web/client/onboarding-api.ts b/packages/lina-ui/client/onboarding-api.ts similarity index 100% rename from packages/lina-web/client/onboarding-api.ts rename to packages/lina-ui/client/onboarding-api.ts diff --git a/packages/lina-web/client/onboarding-model.ts b/packages/lina-ui/client/onboarding-model.ts similarity index 100% rename from packages/lina-web/client/onboarding-model.ts rename to packages/lina-ui/client/onboarding-model.ts diff --git a/packages/lina-web/client/onboarding-navigation.ts b/packages/lina-ui/client/onboarding-navigation.ts similarity index 100% rename from packages/lina-web/client/onboarding-navigation.ts rename to packages/lina-ui/client/onboarding-navigation.ts diff --git a/packages/lina-web/client/onboarding-types.ts b/packages/lina-ui/client/onboarding-types.ts similarity index 100% rename from packages/lina-web/client/onboarding-types.ts rename to packages/lina-ui/client/onboarding-types.ts diff --git a/packages/lina-web/client/onboarding.css b/packages/lina-ui/client/onboarding.css similarity index 61% rename from packages/lina-web/client/onboarding.css rename to packages/lina-ui/client/onboarding.css index 177e58e..2c801a4 100644 --- a/packages/lina-web/client/onboarding.css +++ b/packages/lina-ui/client/onboarding.css @@ -10,10 +10,14 @@ display: none; } .onboarding-dialog .dialog-header { - align-items: flex-start; + align-items: center; + flex-wrap: wrap; +} +.onboarding-dialog .dialog-header > div:first-child { + min-width: 0; } .onboarding-kicker { - font-size: 0.72rem; + font-size: 0.8125rem; color: var(--muted); margin-bottom: 4px; text-wrap: balance; @@ -23,22 +27,24 @@ align-items: center; gap: 8px; flex-shrink: 0; + margin-left: auto; } .onboarding-save-state { - font-size: 0.75rem; + font-size: 0.8125rem; color: var(--muted); white-space: nowrap; } .onboarding-progress { - padding: 10px 32px 0; - font-size: 0.82rem; + padding: 8px 24px 0; + font-size: 0.875rem; color: var(--muted); text-wrap: balance; } .onboarding-body { - padding: 24px 32px 12px; + padding: 20px 24px 12px; overflow-y: auto; - min-height: 280px; + overscroll-behavior: contain; + min-height: 0; display: flex; flex-direction: column; gap: 16px; @@ -48,38 +54,46 @@ .onboarding-question, .onboarding-progress-copy { line-height: 1.7; - text-wrap: pretty; + text-wrap: balance; + overflow-wrap: anywhere; max-width: 42rem; } .onboarding-disclaimer { color: var(--muted); - font-size: 0.85rem; + font-size: 0.875rem; } .onboarding-stale { color: var(--warning); - font-size: 0.85rem; + font-size: 0.875rem; line-height: 1.7; } .onboarding-field { display: flex; flex-direction: column; gap: 6px; - font-size: 0.85rem; + font-size: 0.875rem; } .onboarding-field textarea, .onboarding-field input { background: var(--input-bg); color: var(--text); border: 1px solid var(--input-border); - border-radius: 7px; - padding: 10px; + border-radius: var(--control-radius); + padding: 7px 11px; width: 100%; font: inherit; - min-height: 44px; + min-height: 36px; + line-height: 20px; } .onboarding-field textarea { min-height: 4.5rem; resize: vertical; + padding: 10px 12px; + line-height: 1.6; +} +.onboarding-field :is(input, textarea):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .onboarding-review { list-style: none; @@ -94,7 +108,7 @@ flex-direction: column; gap: 4px; padding-bottom: 12px; - border-bottom: 1px solid var(--line); + overflow-wrap: anywhere; } .onboarding-blank { color: var(--muted); @@ -105,7 +119,7 @@ gap: 10px; } .onboarding-section h3 { - font-size: 0.9rem; + font-size: 0.875rem; } .onboarding-agent-row, .onboarding-scene { @@ -118,29 +132,41 @@ display: flex; align-items: center; gap: 8px; - min-height: 44px; - font-size: 0.85rem; + min-height: 36px; + font-size: 0.875rem; +} +.onboarding-share input { + width: 16px; + height: 16px; + margin: 0; + flex-shrink: 0; + accent-color: var(--accent); } .onboarding-choice { text-align: left; - background: var(--surface-raised); + background: transparent; color: var(--text); - border: 1px solid var(--line-strong); - border-radius: 12px; - padding: 16px 18px; - min-height: 72px; + border: 0; + border-radius: var(--control-radius); + padding: 12px; + min-height: 60px; + font-size: 0.875rem; display: flex; flex-direction: column; gap: 6px; } .onboarding-choice small { color: var(--muted); - font-size: 0.8rem; + font-size: 0.8125rem; text-wrap: balance; } -.onboarding-choice:hover { +.onboarding-choice:hover:not(:disabled) { background: var(--selection); } +.onboarding-choice:disabled, +.onboarding-chapter:disabled { + opacity: 0.55; +} .onboarding-chapters { display: flex; gap: 8px; @@ -148,23 +174,29 @@ padding-bottom: 4px; } .onboarding-chapter { - border: 1px solid var(--line); - background: var(--surface-raised); - color: var(--text); - border-radius: 999px; + border: 0; + background: transparent; + color: var(--muted); + border-radius: var(--control-radius); padding: 8px 12px; - min-height: 44px; + min-height: 36px; + font-size: 0.875rem; display: flex; flex-direction: column; align-items: flex-start; white-space: nowrap; } .onboarding-chapter[aria-current="true"] { - border-color: var(--accent); + background: var(--selection); + color: var(--text); +} +.onboarding-chapter:hover:not(:disabled) { + background: var(--surface-raised); + color: var(--text); } .onboarding-chapter small { color: var(--muted); - font-size: 0.7rem; + font-size: 0.8125rem; } .onboarding-answers { margin: 0; @@ -175,21 +207,34 @@ } .onboarding-proposal { margin: 0; - padding: 14px 16px; - border-left: 3px solid var(--accent); - background: var(--surface-raised); - border-radius: 0 8px 8px 0; + padding: 12px 0; + line-height: 1.7; + overflow-wrap: anywhere; } .onboarding-footer { display: flex; justify-content: space-between; + align-items: center; + flex-wrap: wrap; gap: 12px; - padding: 16px 32px 20px; - border-top: 1px solid var(--line); + padding: 12px 24px 20px; + flex-shrink: 0; position: sticky; bottom: 0; background: var(--surface-main); } +#onboarding-primary { + margin-left: auto; + background: var(--button-bg); + color: var(--button-text); +} +#onboarding-primary:hover:not(:disabled) { + background: var(--button-hover); +} +#onboarding-status { + margin: 0; + padding: 8px 24px; +} .onboarding-transcript { display: flex; flex-direction: column; @@ -200,8 +245,9 @@ .onboarding-assistant { margin: 0; padding: 10px 12px; - border-radius: 10px; + border-radius: var(--control-radius); line-height: 1.6; + overflow-wrap: anywhere; } .onboarding-user { background: var(--selection); @@ -209,29 +255,29 @@ max-width: 90%; } .onboarding-assistant { - background: var(--surface-raised); + padding-inline: 0; max-width: 90%; } -@media (max-width: 390px) { - .onboarding-dialog { - width: calc(100vw - 12px); - max-height: calc(100dvh - 12px); - } +@media (max-width: 767px) { .onboarding-body, .onboarding-footer, - .onboarding-progress { + .onboarding-progress, + #onboarding-status { padding-left: 16px; padding-right: 16px; } - .onboarding-footer { - flex-wrap: wrap; - } .onboarding-footer .secondary-button, .onboarding-footer .text-button, .onboarding-choice, - .onboarding-chapter { + .onboarding-chapter, + .onboarding-share, + .onboarding-field input { min-height: 44px; } + .onboarding-field input, + .onboarding-field textarea { + font-size: 1rem; + } } @media (prefers-reduced-motion: reduce) { .onboarding-dialog * { diff --git a/packages/lina-web/client/onboarding.ts b/packages/lina-ui/client/onboarding.ts similarity index 100% rename from packages/lina-web/client/onboarding.ts rename to packages/lina-ui/client/onboarding.ts diff --git a/packages/lina-web/client/presence.ts b/packages/lina-ui/client/presence.ts similarity index 100% rename from packages/lina-web/client/presence.ts rename to packages/lina-ui/client/presence.ts diff --git a/packages/lina-web/client/pwa.css b/packages/lina-ui/client/pwa.css similarity index 63% rename from packages/lina-web/client/pwa.css rename to packages/lina-ui/client/pwa.css index 0e25e9a..62491c8 100644 --- a/packages/lina-web/client/pwa.css +++ b/packages/lina-ui/client/pwa.css @@ -1,14 +1,3 @@ -#attach-file { - background: none; - border: 0; - padding: 8px 0; - font-size: 0.75rem; - color: var(--muted); - white-space: nowrap; -} -#attach-file:disabled { - opacity: 0.55; -} #draft-files, .message-files { display: flex; @@ -16,15 +5,21 @@ gap: 6px; margin: 8px 0; } +#draft-files { + flex-wrap: nowrap; + overflow-x: auto; +} .file-chip { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; - padding: 4px 8px; - border: 1px solid var(--line); - border-radius: 6px; - font-size: 0.75rem; + padding: 4px 10px; + border: 0; + border-radius: var(--control-radius); + background: var(--surface-raised); + font-size: 0.8125rem; + line-height: 20px; max-width: 100%; white-space: normal; } @@ -36,31 +31,60 @@ border: 0; background: transparent; color: var(--muted); - padding: 5px 3px; - font-size: 0.7rem; + padding: 4px; + min-width: 28px; + min-height: 28px; + border-radius: var(--control-radius); + font-size: 0.8125rem; +} +.file-chip button:hover:not(:disabled) { + color: var(--text); + background: var(--selection); +} +#draft-files .file-chip { + flex: 0 0 auto; + flex-wrap: nowrap; +} +#draft-files .file-chip a { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .file-preview { max-width: min(640px, calc(100vw - 24px)); width: 100%; max-height: 80dvh; - border: 1px solid var(--line-strong); - border-radius: 12px; - padding: 20px; - color: var(--text); - background: var(--surface-main); + padding: 16px 24px 24px; } -.file-preview::backdrop { - background: #0008; +.file-preview[open] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; +} +.file-preview > strong { + align-self: center; + font-weight: 500; + font-size: 0.875rem; + overflow-wrap: anywhere; } .file-preview > button { - float: right; background: none; - color: var(--accent); + color: var(--muted); border: 0; - padding: 5px 10px; + border-radius: var(--control-radius); + padding: 8px 12px; + min-height: 36px; + font-size: 0.875rem; +} +.file-preview > button:hover { + color: var(--text); + background: var(--surface-raised); } .file-preview > div { - margin-top: 20px; + grid-column: 1 / -1; + min-width: 0; + min-height: 0; overflow: auto; } .file-preview img { @@ -75,16 +99,13 @@ overflow-wrap: anywhere; } @media (max-width: 767px) { - #attach-file, .file-chip button, - .file-chip a { + .file-chip a, + .file-preview > button { min-height: 44px; display: inline-flex; align-items: center; } - .composer-bottom { - justify-content: space-between; - } .chat-header { gap: 6px; } @@ -94,6 +115,12 @@ .chat-header h1 { font-size: 1rem; } + .file-preview { + padding: 12px 16px 20px; + } + .markdown-copy-button { + min-height: 44px; + } } @media (max-width: 360px) { .connection-status { @@ -108,23 +135,3 @@ flex-wrap: wrap; } } - -#draft-files { - flex-wrap: nowrap; - overflow-x: auto; -} -#draft-files .file-chip { - flex: 0 0 auto; - flex-wrap: nowrap; -} -#draft-files .file-chip a { - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -@media (max-width: 767px) { - .markdown-copy-button { - min-height: 44px; - } -} diff --git a/packages/lina-web/client/pwa.ts b/packages/lina-ui/client/pwa.ts similarity index 94% rename from packages/lina-web/client/pwa.ts rename to packages/lina-ui/client/pwa.ts index 3980404..e87ba84 100644 --- a/packages/lina-web/client/pwa.ts +++ b/packages/lina-ui/client/pwa.ts @@ -1,9 +1,12 @@ -import type { DraftStore } from "./draft.ts"; +import type { DraftStore } from "../../lina-client/src/draft.ts"; import { element, setText } from "./render.ts"; type InstallEvent = Event & { prompt(): Promise<{ outcome: string }> }; /** Only an explicit update click may reload; activation alone never clears input. */ -export function installPwa(drafts: DraftStore, fullText: () => string): void { +export function installPwa( + draftSource: DraftStore | (() => DraftStore), + fullText: () => string, +): void { const install = element("install-app", HTMLButtonElement), update = element("update-app", HTMLButtonElement), status = element("app-install-status", HTMLParagraphElement), @@ -46,7 +49,14 @@ export function installPwa(drafts: DraftStore, fullText: () => string): void { }); const persist = () => { const text = fullText(); - if (drafts.saveDraft(text) || !text) return true; + if ( + (typeof draftSource === "function" + ? draftSource() + : draftSource + ).saveDraft(text) || + !text + ) + return true; setText(updateStatus, "입력 저장 실패 · 내용 복사 후 새로고침"); return false; }; diff --git a/packages/lina-ui/client/reading-position.ts b/packages/lina-ui/client/reading-position.ts new file mode 100644 index 0000000..bc6b529 --- /dev/null +++ b/packages/lina-ui/client/reading-position.ts @@ -0,0 +1,92 @@ +export type ReadingPosition = { + id: string | null; + offset: number; + bottom: boolean; +}; + +export function captureReadingPosition(): ReadingPosition { + const scroll = document.getElementById("conversation-scroll"); + if (!scroll) return { id: null, offset: 0, bottom: true }; + const top = scroll.getBoundingClientRect().top; + const node = Array.from( + scroll.querySelectorAll("[data-message-id]"), + ).find((item) => item.getBoundingClientRect().bottom > top); + return { + id: node?.dataset["messageId"] ?? null, + offset: node ? node.getBoundingClientRect().top - top : 0, + bottom: + !node || + scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 80, + }; +} + +export function restoreReadingPosition(position: ReadingPosition): boolean { + const scroll = document.getElementById("conversation-scroll"); + if (!scroll || !scroll.clientHeight) return false; + if (position.bottom) { + scroll.scrollTop = scroll.scrollHeight; + return true; + } + const node = Array.from( + scroll.querySelectorAll("[data-message-id]"), + ).find((item) => item.dataset["messageId"] === position.id); + if (!node) return false; + scroll.scrollTop += + node.getBoundingClientRect().top - + scroll.getBoundingClientRect().top - + position.offset; + return true; +} + +export function saveReadingPosition( + agentId: string, + sessionId: string, + position: ReadingPosition, +): void { + try { + localStorage.setItem( + `lina.reading.v1.${agentId}`, + JSON.stringify({ sessionId, position }), + ); + } catch { + /* Keep in-memory restoration. */ + } +} +export function loadReadingPosition( + agentId: string, + sessionId: string, +): ReadingPosition | undefined { + try { + const value: unknown = JSON.parse( + localStorage.getItem(`lina.reading.v1.${agentId}`) ?? "null", + ); + if ( + !value || + typeof value !== "object" || + !("sessionId" in value) || + value.sessionId !== sessionId || + !("position" in value) + ) + return; + const position = value.position; + if ( + position && + typeof position === "object" && + "id" in position && + (typeof position.id === "string" || position.id === null) && + "offset" in position && + typeof position.offset === "number" && + Number.isFinite(position.offset) && + "bottom" in position && + typeof position.bottom === "boolean" + ) + return { + id: position.id, + offset: position.offset, + bottom: position.bottom, + }; + } catch { + /* Corrupted optional device state does not block conversation. */ + } + return; +} diff --git a/packages/lina-web/client/recovery.css b/packages/lina-ui/client/recovery.css similarity index 100% rename from packages/lina-web/client/recovery.css rename to packages/lina-ui/client/recovery.css diff --git a/packages/lina-web/client/recovery.ts b/packages/lina-ui/client/recovery.ts similarity index 96% rename from packages/lina-web/client/recovery.ts rename to packages/lina-ui/client/recovery.ts index 8c3136d..a7755be 100644 --- a/packages/lina-web/client/recovery.ts +++ b/packages/lina-ui/client/recovery.ts @@ -1,5 +1,5 @@ -import type { ServerFrame } from "../src/protocol.ts"; -import type { DraftStore, PendingSend } from "./draft.ts"; +import type { DraftStore, PendingSend } from "../../lina-client/src/draft.ts"; +import type { ServerFrame } from "../../lina-client/src/protocol.ts"; import { element } from "./render.ts"; /** Unconfirmed sends stay separate from authoritative server history. */ diff --git a/packages/lina-web/client/render.ts b/packages/lina-ui/client/render.ts similarity index 91% rename from packages/lina-web/client/render.ts rename to packages/lina-ui/client/render.ts index 9a1e655..6446cd4 100644 --- a/packages/lina-web/client/render.ts +++ b/packages/lina-ui/client/render.ts @@ -1,6 +1,10 @@ +import type { + ChatMessage, + ChatModel, + MessageStatus, +} from "../../lina-client/src/model.ts"; import { renderUserBody } from "./attachments.ts"; import { renderMarkdown } from "./markdown.ts"; -import type { ChatMessage, ChatModel, MessageStatus } from "./model.ts"; export function element( id: string, @@ -29,17 +33,25 @@ const STATUS: Record = { }; export function createRenderer( - model: ChatModel, + source: ChatModel | (() => ChatModel), restore: (text: string) => void, expand?: (id: string) => void, sessionId: () => string | undefined = () => undefined, ) { const messages = element("messages", HTMLDivElement); const scroll = element("conversation-scroll", HTMLDivElement); + const getModel = () => (typeof source === "function" ? source() : source); + let bound = getModel(); const nodes = new Map(); const rendered = new WeakMap(); return () => { + const model = getModel(); + if (bound !== model) { + messages.replaceChildren(); + nodes.clear(); + bound = model; + } const follow = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 120; element("welcome", HTMLElement).hidden = model.messages.length > 0; @@ -117,9 +129,10 @@ function messageNode( ): HTMLElement { const article = document.createElement("article"); article.className = `message ${message.role}`; + article.dataset["messageId"] = message.id; article.setAttribute( "aria-label", - message.role === "assistant" ? "Lina" : "나", + message.role === "assistant" ? "에이전트 메시지" : "나", ); const body = document.createElement("div"); body.className = "message-body"; diff --git a/packages/lina-web/client/responsive.css b/packages/lina-ui/client/responsive.css similarity index 80% rename from packages/lina-web/client/responsive.css rename to packages/lina-ui/client/responsive.css index edce18f..4ebb800 100644 --- a/packages/lina-web/client/responsive.css +++ b/packages/lina-ui/client/responsive.css @@ -28,9 +28,12 @@ .main { flex: 1; } + .conversation-scroll { + scrollbar-gutter: auto; + } .chat-header { - min-height: 68px; - padding: 12px 18px; + min-height: 60px; + padding: max(8px, env(safe-area-inset-top)) 8px 8px; } .breadcrumb { display: none; @@ -59,7 +62,7 @@ } .suggestions button { min-height: 44px; - font-size: 0.66rem; + font-size: 0.8125rem; padding: 10px; } .suggestions button span { @@ -68,21 +71,6 @@ .messages { padding: 24px 16px 4px; } - .composer-area { - padding: 8px 16px calc(12px + env(safe-area-inset-bottom)); - } - .composer { - padding: 12px; - } - #composer-hint { - display: none; - } - .composer-bottom { - justify-content: flex-end; - } - #send { - min-height: 44px; - } .text-button { min-height: 44px; } @@ -103,10 +91,6 @@ } @media (max-width: 767px) { - .chat-header { - min-height: 60px; - padding: 10px 16px; - } .message.assistant { max-width: 100%; } diff --git a/packages/lina-web/client/search-view.ts b/packages/lina-ui/client/search-view.ts similarity index 96% rename from packages/lina-web/client/search-view.ts rename to packages/lina-ui/client/search-view.ts index 77812a9..47d1b42 100644 --- a/packages/lina-web/client/search-view.ts +++ b/packages/lina-ui/client/search-view.ts @@ -1,8 +1,11 @@ -import type { ServerFrame } from "../src/protocol.ts"; +import type { ServerFrame } from "../../lina-client/src/protocol.ts"; +import { + type SearchCommand, + SearchModel, +} from "../../lina-client/src/search-model.ts"; import { renderUserBody } from "./attachments.ts"; import { renderMarkdown } from "./markdown.ts"; import { element, setText } from "./render.ts"; -import { type SearchCommand, SearchModel } from "./search-model.ts"; export function createSearch(send: (frame: SearchCommand) => boolean) { const model = new SearchModel(), dialog = element("search-dialog", HTMLDialogElement), diff --git a/packages/lina-web/client/search.css b/packages/lina-ui/client/search.css similarity index 71% rename from packages/lina-web/client/search.css rename to packages/lina-ui/client/search.css index 5656921..811b191 100644 --- a/packages/lina-web/client/search.css +++ b/packages/lina-ui/client/search.css @@ -4,8 +4,8 @@ } .search-form { display: flex; - gap: 10px; - margin: 18px 20px 8px; + gap: 8px; + margin: 8px 24px; } .search-form input { min-width: 0; @@ -13,29 +13,30 @@ background: var(--input-bg); color: var(--text); border: 1px solid var(--input-border); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.9rem; + border-radius: var(--control-radius); + padding: 7px 11px; + min-height: 36px; + line-height: 20px; + font-size: 0.875rem; } .search-form input::placeholder { color: var(--muted); } .search-status { - padding: 8px 20px; + padding: 8px 24px; color: var(--muted); - font-size: 0.8rem; + font-size: 0.8125rem; min-height: 32px; } .search-results { - padding: 0 10px 14px; + padding: 0 12px 16px; } .search-result { display: block; width: 100%; - padding: 14px 12px; + padding: 12px; border: 0; - border-bottom: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--control-radius); background: transparent; color: var(--text); text-align: left; @@ -46,9 +47,9 @@ } .search-result-meta { display: block; - font-size: 0.74rem; + font-size: 0.8125rem; color: var(--muted); - margin-bottom: 7px; + margin-bottom: 4px; } .search-result-text { display: -webkit-box; @@ -57,17 +58,18 @@ overflow: hidden; overflow-wrap: anywhere; line-height: 1.7; - font-size: 0.88rem; + font-size: 0.875rem; white-space: pre-wrap; } .search-pages { display: flex; justify-content: space-between; - border-top: 1px solid var(--line); - padding: 8px 14px; + align-items: center; + gap: 8px; + padding: 8px 24px 16px; } .search-detail { - padding: 0 20px 20px; + padding: 0 24px 24px; } .search-detail article { font-size: var(--chat-text-size, 0.9375rem); @@ -87,6 +89,14 @@ } .search-form input { font-size: 16px; + min-height: 44px; + } + .search-status, + .search-pages { + padding-inline: 16px; + } + .search-results { + padding-inline: 4px; } .search-detail { padding: 0 16px 16px; diff --git a/packages/lina-ui/client/settings-dom.ts b/packages/lina-ui/client/settings-dom.ts new file mode 100644 index 0000000..2521761 --- /dev/null +++ b/packages/lina-ui/client/settings-dom.ts @@ -0,0 +1,41 @@ +/** The browser DOM and the structural test fixture share this small surface. */ +export interface SettingsNode { + id: string; + className: string; + type: string; + min: string; + max: string; + step: string; + value: string; + checked?: boolean; + disabled: boolean; + textContent: string | null; + append(...nodes: SettingsNode[]): void; + replaceChildren(...nodes: SettingsNode[]): void; + before(...nodes: SettingsNode[]): void; + after(...nodes: SettingsNode[]): void; + querySelectorAll(selector: string): Iterable; + hidden: boolean; + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; + contains(node: unknown): boolean; + select?(): void; + scrollIntoView?(options?: { block: "nearest" }): void; + addEventListener( + type: string, + listener: (event: { + key?: string; + relatedTarget?: unknown; + preventDefault(): void; + stopPropagation(): void; + }) => unknown, + ): void; + checkValidity(): boolean; + reportValidity(): boolean; + focus(): void; +} +export interface SettingsDocument { + location: { href: string }; + getElementById(id: string): SettingsNode | null; + createElement(tag: string): SettingsNode; +} diff --git a/packages/lina-web/client/settings-navigation.css b/packages/lina-ui/client/settings-navigation.css similarity index 64% rename from packages/lina-web/client/settings-navigation.css rename to packages/lina-ui/client/settings-navigation.css index d342a61..eeb8674 100644 --- a/packages/lina-web/client/settings-navigation.css +++ b/packages/lina-ui/client/settings-navigation.css @@ -1,21 +1,20 @@ #settings-dialog { width: min(920px, calc(100vw - 48px)); max-height: calc(100dvh - 48px); - border-radius: 18px; overflow: hidden; } #settings-dialog .dialog-header { position: relative; - padding: 17px 24px; } .settings-heading-copy { display: flex; align-items: baseline; gap: 14px; + min-width: 0; } .settings-heading-copy p { margin: 0; - font-size: 0.76rem; + font-size: 0.8125rem; color: var(--muted); } .settings-workspace { @@ -26,24 +25,24 @@ .settings-navigation { display: flex; flex-direction: column; - gap: 5px; - padding: 20px 12px; + gap: 4px; + padding: 16px 12px; background: var(--surface-sidebar); - border-right: 1px solid var(--line); } .settings-navigation button { display: flex; align-items: center; - gap: 10px; - min-height: 44px; + gap: 8px; + min-height: 36px; border: 0; - border-radius: 8px; + border-radius: var(--control-radius); background: transparent; color: var(--muted); font: inherit; - font-size: 0.84rem; + font-size: 0.875rem; text-align: left; - padding: 10px 12px; + padding: 8px 10px; + line-height: 20px; cursor: pointer; white-space: nowrap; } @@ -54,12 +53,11 @@ .settings-navigation button[aria-selected="true"] { color: var(--text); background: var(--surface-raised); - box-shadow: inset 3px 0 0 var(--accent); - font-weight: 600; + font-weight: 500; } .settings-navigation svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; @@ -73,7 +71,7 @@ overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - padding: 28px 30px 24px; + padding: 24px; } .settings-panel { min-width: 0; @@ -82,57 +80,49 @@ display: none; } .settings-page-header { - margin-bottom: 24px; + margin-bottom: 20px; } .settings-page-header h3 { - font-size: 1.2rem; + font-size: 1rem; font-weight: 600; - letter-spacing: -0.025em; - margin: 0 0 7px; + margin: 0 0 8px; text-wrap: balance; } .settings-page-header p { - font-size: 0.82rem; + font-size: 0.875rem; line-height: 1.7; color: var(--muted); margin: 0; text-wrap: balance; } .settings-panel > section { - margin: 0 0 26px; + margin: 0 0 16px; } .settings-panel > section > h3 { - margin: 0 0 3px; - font-size: 0.78rem; + margin: 0 0 4px; + font-size: 0.8125rem; font-weight: 500; color: var(--muted); } -.settings-panel .setting-row { - padding: 15px 0; -} .settings-panel .model-actions { position: sticky; bottom: -24px; background: var(--surface-main); - padding: 14px 0; - border-top: 1px solid var(--line); + padding: 12px 0; + align-items: center; z-index: 1; } -.settings-panel .model-combobox[data-open="true"] { - z-index: 4; -} .settings-panel .model-trial { - margin-top: 22px; + margin-top: 24px; } .settings-connection-link { display: flex; align-items: center; justify-content: space-between; gap: 16px; - border-bottom: 1px solid var(--line); - margin-bottom: 20px; - padding: 0 0 14px; - font-size: 0.8rem; + margin-bottom: 24px; + padding: 0; + font-size: 0.8125rem; color: var(--muted); } .settings-connection-link button { @@ -144,9 +134,12 @@ } @media (max-width: 767px) { #settings-dialog { - width: calc(100vw - 20px); - max-height: calc(100dvh - 24px); - border-radius: 14px; + width: 100%; + max-height: var(--visible-height, 100dvh); + } + #settings-dialog[open] { + display: flex; + flex-direction: column; } #settings-dialog .dialog-header { padding: 12px 16px; @@ -157,71 +150,47 @@ .settings-workspace { display: flex; flex-direction: column; - height: calc(100dvh - 100px); + flex: 1; + min-height: 0; + height: auto; } .settings-navigation { flex-direction: row; - flex-wrap: wrap; - gap: 3px; + flex-wrap: nowrap; + gap: 4px; padding: 8px; - border-right: 0; - border-bottom: 1px solid var(--line); flex-shrink: 0; } .settings-navigation button { - flex: 1 1 0; + flex: 1 1 auto; justify-content: center; - padding: 9px 5px; + min-height: 44px; + padding: 8px; gap: 6px; - font-size: 0.74rem; + font-size: 0.8125rem; min-width: 0; - white-space: normal; + white-space: nowrap; word-break: keep-all; line-height: 1.25; text-align: center; } - .settings-navigation button[aria-selected="true"] { - box-shadow: inset 0 -2px 0 var(--accent); - } - .settings-navigation svg { - width: 16px; - height: 16px; - } .settings-content { - padding: 22px 16px; + padding: 20px 16px; flex: 1; } - .settings-page-header { - margin-bottom: 20px; - } - .settings-page-header h3 { - font-size: 1.08rem; - } .settings-panel .model-actions { - bottom: -22px; + bottom: -20px; } - .settings-panel .setting-row { - gap: 10px; - } -} -@media (max-width: 390px) { - .settings-navigation button { - font-size: 0.7rem; - gap: 4px; - padding: 8px 3px; + .settings-connection-link { + flex-wrap: wrap; + gap: 8px; } } @media (max-width: 360px) { .settings-navigation svg { display: none; } - .settings-navigation button { - font-size: 0.68rem; - } .settings-content { padding-inline: 12px; } - .settings-connection-link { - align-items: flex-start; - } } diff --git a/packages/lina-ui/client/settings-tab-model.ts b/packages/lina-ui/client/settings-tab-model.ts new file mode 100644 index 0000000..f1b9193 --- /dev/null +++ b/packages/lina-ui/client/settings-tab-model.ts @@ -0,0 +1,2 @@ +export const SETTINGS_TABS = ["general", "connections", "models"] as const; +export type SettingsTab = (typeof SETTINGS_TABS)[number]; diff --git a/packages/lina-ui/client/settings-tabs.ts b/packages/lina-ui/client/settings-tabs.ts new file mode 100644 index 0000000..292f3f9 --- /dev/null +++ b/packages/lina-ui/client/settings-tabs.ts @@ -0,0 +1,56 @@ +import { createElement } from "react"; +import { mountView } from "./components/mount.ts"; +import { SettingsTabsView } from "./components/settings-tabs.tsx"; +import { SETTINGS_TABS, type SettingsTab } from "./settings-tab-model.ts"; + +export function installSettingsTabs( + root: HTMLElement, + changed: (tab: SettingsTab) => void, +) { + let current: SettingsTab = "general"; + const navigation = root.querySelector(".settings-navigation"); + if (!navigation) throw Error("Missing settings navigation"); + const panels = SETTINGS_TABS.map((id) => { + const panel = root.querySelector(`#settings-panel-${id}`); + if (!panel) throw Error("Missing settings panel"); + panel.hidden = id !== current; + return { id, panel }; + }); + const label = navigation.getAttribute("aria-label") ?? "설정 유형"; + // The host stays controller-owned; the single tablist is rendered inside it. + navigation.removeAttribute("role"); + navigation.removeAttribute("aria-label"); + navigation.removeAttribute("aria-orientation"); + const view = mountView(navigation); + const mobile = window.matchMedia("(max-width: 767px)"); + const render = () => + view.render( + createElement(SettingsTabsView, { + value: current, + orientation: mobile.matches ? "horizontal" : "vertical", + label, + select, + }), + ); + const select = (id: SettingsTab, focus = false) => { + if (id !== current) + root + .querySelector(".settings-content") + ?.scrollTo({ top: 0 }); + current = id; + // Content panels remain owned by the existing settings controllers. + for (const item of panels) item.panel.hidden = item.id !== id; + render(); + if (focus) + navigation.querySelector(`#settings-tab-${id}`)?.focus(); + changed(id); + }; + mobile.addEventListener("change", render); + render(); + return { + select, + get current() { + return current; + }, + }; +} diff --git a/packages/lina-web/client/settings.css b/packages/lina-ui/client/settings.css similarity index 64% rename from packages/lina-web/client/settings.css rename to packages/lina-ui/client/settings.css index b1ee27c..bde6fe5 100644 --- a/packages/lina-web/client/settings.css +++ b/packages/lina-ui/client/settings.css @@ -1,12 +1,14 @@ dialog { color: var(--text); background: var(--surface-main); - border: 1px solid var(--line-strong); - border-radius: 14px; + border: 0; + border-radius: var(--menu-radius); + box-shadow: 0 16px 64px #0005; padding: 0; + overscroll-behavior: contain; } dialog::backdrop { - background: #0007; + background: #0008; } .settings-dialog { width: min(520px, calc(100vw - 32px)); @@ -21,21 +23,31 @@ dialog::backdrop { align-items: center; justify-content: space-between; gap: 16px; - padding: 16px 20px; - border-bottom: 1px solid var(--line); + padding: 16px 24px; + flex-shrink: 0; } .dialog-header h2 { font-size: 1rem; font-weight: 600; } +.dialog-header .icon-button { + width: 36px; + height: 36px; + padding: 8px; + border-radius: var(--control-radius); +} +.dialog-header .icon-button svg { + width: 16px; + height: 16px; +} .settings-body { padding: 8px 24px 20px; } .settings-body section { - padding-top: 18px; + padding-top: 16px; } .settings-body h3 { - font-size: 0.76rem; + font-size: 0.8125rem; color: var(--muted); font-weight: 500; margin-bottom: 10px; @@ -46,20 +58,28 @@ dialog::backdrop { justify-content: space-between; gap: 16px; border: 0; - border-bottom: 1px solid var(--line); - padding: 12px 0; + padding: 8px 0; margin: 0; min-width: 0; - font-size: 0.86rem; + min-height: 52px; + font-size: 0.875rem; +} +.setting-row > div:first-child { + min-width: 0; +} +.setting-row > button { + flex-shrink: 0; } .setting-row strong { font-size: inherit; font-weight: 400; } .setting-row p { - font-size: 0.78rem; + font-size: 0.8125rem; color: var(--muted); - line-height: 1.7; + line-height: 1.6; + margin-top: 4px; + overflow-wrap: anywhere; } .setting-options { border: 0; @@ -70,19 +90,19 @@ dialog::backdrop { gap: 12px; flex-wrap: wrap; justify-content: flex-end; - margin-left: auto; } .setting-options label { display: flex; align-items: center; - gap: 6px; + gap: 8px; cursor: pointer; - min-height: 32px; + min-height: 36px; } .setting-options input { accent-color: var(--accent); - width: 15px; - height: 15px; + width: 16px; + height: 16px; + flex-shrink: 0; margin: 0; } .stacked { @@ -92,45 +112,61 @@ dialog::backdrop { .theme-switch { display: flex; gap: 2px; - padding: 3px; + padding: 0; border: 0; - border-radius: 8px; + border-radius: var(--control-radius); background: var(--surface-sidebar); margin: 0; min-width: 0; } .theme-switch button { border: 0; - border-radius: 5px; + border-radius: var(--control-radius); background: transparent; color: var(--muted); - padding: 7px 9px; - min-height: 32px; - font-size: 0.78rem; + padding: 8px 12px; + min-height: 36px; + font-size: 0.875rem; + line-height: 20px; white-space: nowrap; } +.theme-switch button:hover { + color: var(--text); +} .theme-switch button[aria-pressed="true"] { background: var(--surface-raised); color: var(--text); } .secondary-button { - border: 1px solid var(--line-strong); - border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + border-radius: var(--control-radius); background: var(--surface-raised); color: var(--text); padding: 8px 12px; min-height: 36px; - font-size: 0.78rem; + font-size: 0.875rem; + line-height: 20px; white-space: nowrap; } -.secondary-button:hover { +.secondary-button:hover:not(:disabled) { background: var(--selection); } .secondary-button:disabled { opacity: 0.55; } +.primary-button { + background: var(--button-bg); + color: var(--button-text); +} +.primary-button:hover:not(:disabled) { + background: var(--button-hover); +} .install-help { - font-size: 0.78rem; + font-size: 0.8125rem; line-height: 1.8; margin: 10px 0; color: var(--muted); @@ -142,7 +178,7 @@ dialog::backdrop { margin-top: 8px; } .settings-status { - font-size: 0.78rem; + font-size: 0.8125rem; color: var(--warning); line-height: 1.7; margin-top: 12px; @@ -153,14 +189,14 @@ dialog::backdrop { .shortcut-list { margin: 0; padding: 8px 24px 24px; - font-size: 0.85rem; + font-size: 0.875rem; } .shortcut-list div { display: flex; align-items: center; justify-content: space-between; - padding: 16px 0; - border-bottom: 1px solid var(--line); + padding: 12px 0; + gap: 16px; } .shortcut-list dd { margin: 0; @@ -179,7 +215,11 @@ dialog::backdrop { padding: 4px 18px 20px; } .dialog-header { - padding: 10px 16px; + padding: 12px 16px; + } + .dialog-header .icon-button { + width: 44px; + height: 44px; } .setting-row { gap: 10px; diff --git a/packages/lina-web/client/settings.ts b/packages/lina-ui/client/settings.ts similarity index 96% rename from packages/lina-web/client/settings.ts rename to packages/lina-ui/client/settings.ts index 5d951a2..91c82de 100644 --- a/packages/lina-web/client/settings.ts +++ b/packages/lina-ui/client/settings.ts @@ -1,10 +1,11 @@ -import { installHubSettings } from "./hub-settings.ts"; -import { installModelSettings } from "./model-settings.ts"; import { type Preferences, readPreferences, savePreferences, -} from "./preferences.ts"; +} from "../../lina-client/src/preferences.ts"; +import { installHubSettings } from "./hub-settings.ts"; +import { createModelCombobox } from "./model-combobox.ts"; +import { installModelSettings } from "./model-settings.ts"; import { element, setText } from "./render.ts"; import { installSettingsTabs } from "./settings-tabs.ts"; import { installThemeControls } from "./theme-controls.ts"; @@ -19,7 +20,7 @@ export function createSettings() { } catch {} let preferences = readPreferences(storage), returnFocus: HTMLElement | undefined; - const models = installModelSettings(dialog); + const models = installModelSettings(dialog, createModelCombobox); let modelsLoaded = false, hubLoaded = false; const hub = installHubSettings(dialog, { diff --git a/packages/lina-web/client/sidebar-resize.ts b/packages/lina-ui/client/sidebar-resize.ts similarity index 98% rename from packages/lina-web/client/sidebar-resize.ts rename to packages/lina-ui/client/sidebar-resize.ts index 71e010c..d83ff36 100644 --- a/packages/lina-web/client/sidebar-resize.ts +++ b/packages/lina-ui/client/sidebar-resize.ts @@ -2,7 +2,7 @@ import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, -} from "./preferences.ts"; +} from "../../lina-client/src/preferences.ts"; import { element } from "./render.ts"; import type { createSettings } from "./settings.ts"; export function installSidebarResize( diff --git a/packages/lina-web/client/sidebar.css b/packages/lina-ui/client/sidebar.css similarity index 55% rename from packages/lina-web/client/sidebar.css rename to packages/lina-ui/client/sidebar.css index a13753e..252a505 100644 --- a/packages/lina-web/client/sidebar.css +++ b/packages/lina-ui/client/sidebar.css @@ -2,10 +2,10 @@ position: relative; background: var(--surface-sidebar); color: var(--text); - border-right: 1px solid var(--line); + border-right: 0; display: flex; flex-direction: column; - padding: 20px 12px 14px; + padding: 12px 8px; min-width: 0; } .sidebar-top { @@ -13,15 +13,15 @@ align-items: center; justify-content: space-between; gap: 8px; - margin-bottom: 22px; + margin-bottom: 8px; } .brand { display: flex; align-items: center; gap: 10px; padding: 0 10px; - font-size: 1.25rem; - font-weight: 650; + font-size: 1rem; + font-weight: 600; letter-spacing: -0.02em; } .brand-mark { @@ -45,11 +45,11 @@ display: inline-grid; place-items: center; flex-shrink: 0; - width: 36px; - height: 36px; - padding: 8px; + width: var(--control-size); + height: var(--control-size); + padding: 6px; border: 0; - border-radius: 7px; + border-radius: var(--control-radius); background: transparent; color: var(--muted); } @@ -58,8 +58,8 @@ color: var(--text); } svg { - width: 20px; - height: 20px; + width: var(--icon-size); + height: var(--icon-size); fill: none; stroke: currentColor; stroke-width: 1.7; @@ -72,17 +72,17 @@ svg { align-items: center; gap: 10px; width: 100%; - padding: 10px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--input-bg); + padding: 8px 10px; + border: 0; + border-radius: var(--control-radius); + background: transparent; color: var(--muted); text-align: left; - font-size: 0.82rem; - min-height: 42px; + font-size: var(--font-ui); + min-height: 36px; } .sidebar-search:hover { - border-color: var(--line-strong); + background: var(--hover); color: var(--text); } .sidebar-search kbd { @@ -92,7 +92,7 @@ svg { white-space: nowrap; } .sidebar-content { - margin-top: 28px; + margin-top: 4px; min-height: 0; overflow-y: auto; } @@ -110,7 +110,7 @@ svg { padding: 12px 10px; border: 0; border-radius: 9px; - background: var(--selection); + background: transparent; color: var(--text); text-align: left; } @@ -161,7 +161,8 @@ svg { .account-area { position: relative; margin-top: auto; - padding-top: 20px; + padding-top: 8px; + border-top: 1px solid var(--line); } .account { display: flex; @@ -195,41 +196,6 @@ svg { margin-left: auto; width: 16px; } -.account-menu { - position: absolute; - z-index: 8; - bottom: calc(100% - 12px); - left: 0; - min-width: 232px; - padding: 6px; - border: 1px solid var(--line-strong); - border-radius: 11px; - background: var(--surface-raised); -} -.account-menu button { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 12px 10px; - border: 0; - border-radius: 7px; - background: transparent; - color: var(--text); - font-size: 0.84rem; - text-align: left; - min-height: 44px; -} -.account-menu button:hover, -.account-menu button:focus-visible { - background: var(--selection); -} -.account-menu kbd { - margin-left: auto; - color: var(--muted); - font: inherit; - font-size: 0.72rem; -} #close-sidebar, #open-sidebar { display: none; @@ -237,7 +203,18 @@ svg { .chat-title { display: flex; align-items: center; - gap: 10px; + gap: 8px; + min-width: 0; + flex: 1; +} +#chat-agent-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#current-agent-settings { + margin-left: auto; } .sidebar-drawer { margin: 0; @@ -280,9 +257,6 @@ svg { } } @media (max-width: 767px) { - .workspace > .sidebar { - display: none; - } #open-sidebar, #close-sidebar { display: inline-grid; @@ -295,7 +269,7 @@ svg { height: 44px; } .sidebar-top { - margin-bottom: 18px; + margin-bottom: 8px; } } @@ -338,3 +312,205 @@ svg { display: none; } } + +/* One stable messenger row for every agent, including the active conversation. */ +.agent-filter-field { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 8px 12px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--control-radius); + background: var(--surface-raised); + color: var(--muted); +} +.agent-filter-field:focus-within { + border-color: var(--muted); +} +.agent-filter-field svg { + width: 16px; + height: 16px; +} +#agent-filter { + width: 100%; + min-width: 0; + min-height: 36px; + padding: 6px 0; + border: 0; + outline: none; + background: transparent; + color: var(--text); + font-size: var(--font-caption); +} +#agent-filter::placeholder { + color: var(--muted); +} +#agent-list-status { + padding: 0 12px 8px; + font-size: var(--font-caption); + line-height: 1.6; + color: var(--muted); +} +#other-agents { + padding: 2px 0 8px; +} +#other-agents .agent-row { + margin: 0; + min-height: 64px; + border-radius: var(--control-radius); + align-items: stretch; +} +#other-agents .agent-row:hover { + background: var(--hover); +} +#other-agents .agent-row.selected { + background: var(--selection); +} +#other-agents .agent-link { + min-width: 0; + flex: 1; + width: auto; + padding: 10px 0 10px 10px; + gap: 10px; + background: transparent; +} +#other-agents .agent-link:hover { + background: transparent; +} +#other-agents .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + padding: 0; + color: var(--text); + background: var(--surface-raised); + font-weight: 500; + line-height: 1; + font-family: inherit; + font-size: 1rem; + font-style: normal; + letter-spacing: normal; + overflow: hidden; +} +#other-agents .avatar img { + border-radius: inherit; +} +#other-agents .agent-copy { + overflow: hidden; +} +#other-agents .agent-line { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +#other-agents strong { + min-width: 0; + flex: 1; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#other-agents time { + flex-shrink: 0; + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + color: var(--muted); +} +#other-agents small { + flex: 1; + min-width: 0; + font-size: 0.75rem; + line-height: 1.5; + margin-top: 3px; +} +#other-agents .unread strong { + font-weight: 650; +} +#other-agents .unread small { + color: var(--text); +} +#other-agents .needs-confirmation small { + color: var(--warning); +} +#other-agents .agent-unread { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 50%; + background: var(--text); +} +#other-agents .agent-working { + flex-shrink: 0; + font-size: 0.625rem; + color: var(--muted); +} +#other-agents .agent-more { + align-self: center; + min-width: 36px; + width: 36px; + min-height: 32px; + padding: 4px; + border-radius: var(--control-radius); + display: grid; + place-items: center; +} +#other-agents .agent-more:hover { + color: var(--text); + background: var(--surface-raised); +} +#other-agents button:focus-visible { + outline-offset: -2px; +} +#agent-actions { + min-width: 200px; + max-width: calc(100vw - 16px); + border-radius: var(--menu-radius); + padding: 4px; +} +#agent-actions button { + min-height: 40px; + padding: 10px 12px; + border-radius: var(--control-radius); +} +#agent-actions button:focus-visible { + outline-offset: -2px; + background: var(--selection); +} +.chat-header #current-agent-settings { + padding: 6px; +} +@media (max-width: 767px) { + #agent-filter { + min-height: 44px; + font-size: 16px; + } + #other-agents .agent-row { + min-height: 76px; + } + #other-agents .avatar { + width: 48px; + height: 48px; + } + #other-agents .agent-more { + min-width: 48px; + width: 48px; + min-height: 48px; + } + #other-agents .agent-link { + padding: 10px 0 10px 10px; + } + #other-agents strong { + font-size: 0.9375rem; + } + #other-agents small { + font-size: 0.8125rem; + } + #agent-actions button { + min-height: 48px; + } +} diff --git a/packages/lina-web/client/styles.css b/packages/lina-ui/client/styles.css similarity index 94% rename from packages/lina-web/client/styles.css rename to packages/lina-ui/client/styles.css index b167354..650db6e 100644 --- a/packages/lina-web/client/styles.css +++ b/packages/lina-ui/client/styles.css @@ -1,5 +1,6 @@ @import "./tokens.css"; @import "./base.css"; +@import "./controls.css"; @import "./sidebar.css"; @import "./chat.css"; @import "./composer.css"; @@ -20,6 +21,7 @@ @import "./tasks.css"; @import "./onboarding.css"; @import "./intro.css"; +@import "./navigation.css"; .intro-entry-link { font-size: 12px; diff --git a/packages/lina-ui/client/task-list.ts b/packages/lina-ui/client/task-list.ts new file mode 100644 index 0000000..2127d6a --- /dev/null +++ b/packages/lina-ui/client/task-list.ts @@ -0,0 +1,123 @@ +import { + type TaskRecord, + taskStatusLabel, +} from "../../lina-client/src/task-types.ts"; +import type { SettingsDocument, SettingsNode } from "./model-settings.ts"; + +export type TaskListScope = "selected" | "all"; +export type TaskAgent = { id: string; name: string }; + +export function parseTaskAgents(value: unknown): TaskAgent[] { + if ( + !value || + typeof value !== "object" || + !("agents" in value) || + !Array.isArray(value.agents) + ) + return []; + return value.agents.flatMap((item: unknown) => { + if ( + !item || + typeof item !== "object" || + !("id" in item) || + typeof item.id !== "string" || + !/^[a-z][a-z0-9-]{0,47}$/.test(item.id) + ) + return []; + return [ + { + id: item.id, + name: + "name" in item && typeof item.name === "string" && item.name.trim() + ? item.name + : item.id, + }, + ]; + }); +} + +/** Renders the same list node after the shell moves it between columns/screens. */ +export function createTaskList(options: { + document: SettingsDocument; + list: SettingsNode; + status: SettingsNode; + onOpen: (id: string, opener: SettingsNode) => Promise; + onScope: (scope: TaskListScope) => Promise; +}) { + const { document: doc, list, status } = options; + let filter = doc.getElementById("task-scope"); + if (!filter) { + filter = doc.createElement("select"); + filter.id = "task-scope"; + status.before(filter); + } + filter.className = "task-scope"; + filter.setAttribute("aria-label", "작업 담당 범위"); + const selected = doc.createElement("option"); + selected.value = "selected"; + const all = doc.createElement("option"); + all.value = "all"; + all.textContent = "전체 에이전트"; + filter.replaceChildren(selected, all); + const scopeNode = filter; + scopeNode.addEventListener("change", () => + options.onScope(scopeNode.value === "all" ? "all" : "selected"), + ); + let signature = ""; + return { + render( + tasks: TaskRecord[], + ownerId: string, + scope: TaskListScope, + agents: TaskAgent[], + selectedId: string | null, + ) { + const names = new Map(agents.map((agent) => [agent.id, agent.name])); + selected.textContent = `담당: ${names.get(ownerId) ?? ownerId}`; + scopeNode.value = scope; + const visible = tasks.filter( + (task) => scope === "all" || task.ownerAgentId === ownerId, + ); + const rows = visible.map((task) => ({ + task, + owner: names.get(task.ownerAgentId) ?? "담당 미지정", + })); + const next = JSON.stringify([rows, selectedId]); + if (next === signature) return visible.length; + signature = next; + const active = ( + doc as SettingsDocument & { activeElement?: SettingsNode } + ).activeElement; + const focusId = active && list.contains(active) ? active.id : undefined; + const scroll = (list as SettingsNode & { scrollTop?: number }).scrollTop; + list.replaceChildren(); + for (const { task, owner } of rows) { + const button = doc.createElement("button"); + button.type = "button"; + button.id = `task-open-${task.id}`; + button.className = "task-link"; + button.setAttribute( + "aria-current", + task.id === selectedId ? "true" : "false", + ); + const title = doc.createElement("span"); + title.className = "task-link-title"; + title.textContent = task.title; + const meta = doc.createElement("span"); + meta.className = "task-link-meta"; + meta.textContent = `${owner} · ${taskStatusLabel(task.status)}${task.pendingApprovals.length ? ` · 확인 요청 ${task.pendingApprovals.length}개` : ""}`; + button.append(title, meta); + button.setAttribute( + "aria-label", + `${task.title} · ${meta.textContent}`, + ); + button.addEventListener("click", () => options.onOpen(task.id, button)); + list.append(button); + } + if (focusId) doc.getElementById(focusId)?.focus(); + if (scroll !== undefined) + (list as SettingsNode & { scrollTop: number }).scrollTop = scroll; + return visible.length; + }, + }; +} diff --git a/packages/lina-web/client/task-view.ts b/packages/lina-ui/client/task-view.ts similarity index 80% rename from packages/lina-web/client/task-view.ts rename to packages/lina-ui/client/task-view.ts index 53a5f75..151ca1d 100644 --- a/packages/lina-web/client/task-view.ts +++ b/packages/lina-ui/client/task-view.ts @@ -1,5 +1,31 @@ +import { + TaskDraftStore, + type TaskStorage, +} from "../../lina-client/src/task-drafts.ts"; +import { + type ApprovalDecision, + type TaskApproval, + type TaskRecord, + taskStatusLabel, +} from "../../lina-client/src/task-types.ts"; + +export { + type ApprovalDecision, + type TaskApproval, + type TaskRecord, + taskStatusLabel, +} from "../../lina-client/src/task-types.ts"; + import { agentRequest, type RequestFn } from "./agent-editor.ts"; import type { SettingsDocument, SettingsNode } from "./model-settings.ts"; +import { + createTaskList, + parseTaskAgents, + type TaskAgent, + type TaskListScope, +} from "./task-list.ts"; + +export type { TaskListScope } from "./task-list.ts"; type TaskNode = SettingsNode & { open?: boolean; @@ -19,32 +45,6 @@ const RUNNING = new Set([ "needs_attention", ]); -export type TaskApproval = { - id: string; - method: string; - params: unknown; - createdAt: string; -}; - -export type ApprovalDecision = - | "accept" - | "decline" - | "cancel" - | "acceptForSession"; - -export type TaskRecord = { - id: string; - threadId: string | null; - ownerAgentId: string; - title: string; - cwd: string; - status: string; - revision: number; - updatedAt: string; - model: string | null; - pendingApprovals: TaskApproval[]; -}; - export type HistoryRow = { kind: "user" | "assistant" | "status" | "tool" | "error"; text: string; @@ -239,19 +239,6 @@ export function compactThreadHistory(thread: TaskThread | null): HistoryRow[] { return rows; } -export function taskStatusLabel(status: string): string { - if (status === "inProgress" || status === "running") return "진행 중"; - if (status === "waiting_approval") return "확인 대기"; - if (status === "waiting_input") return "입력 대기"; - if (status === "needs_attention") return "확인 필요"; - if (status === "creating") return "만드는 중"; - if (status === "completed") return "완료"; - if (status === "interrupted") return "중단됨"; - if (status === "failed") return "실패"; - if (status === "queued" || status === "idle") return "대기"; - return status; -} - export function approvalMethodLabel(method: string): string { if (method.includes("commandExecution") || method === "execCommandApproval") return "명령 실행"; @@ -330,12 +317,25 @@ function message(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message : fallback; } +function browserTaskStorage(): TaskStorage | undefined { + try { + return (globalThis as { localStorage?: TaskStorage }).localStorage; + } catch { + return undefined; + } +} + export function installTasks(options: { ownerAgentId: string; + storage?: TaskStorage; document?: SettingsDocument; request?: RequestFn; hidden?: () => boolean; scheduler?: (tick: () => void) => () => void; + scope?: TaskListScope; + onShowList?: () => void; + onOpen?: (id: string) => void; + onScopeChange?: (scope: TaskListScope, ownerAgentId: string) => void; }) { const doc = options.document ?? browserDocument(); const request = options.request ?? agentRequest; @@ -368,6 +368,9 @@ export function installTasks(options: { const interrupt = find("task-interrupt"); const owner = find("task-owner"); const handover = find("task-handover"); + const drafts = new TaskDraftStore( + options.storage ?? (options.document ? undefined : browserTaskStorage()), + ); let generation = 0, busy = false, tasks: TaskRecord[] = [], @@ -380,7 +383,38 @@ export function installTasks(options: { stopped = false, liveGate: Promise | undefined, liveQueued = false, - listSignature = ""; + detailVisible = false, + listLoading = false, + ownerAgentId = options.ownerAgentId, + scope: TaskListScope = options.scope ?? drafts.scope(); + let agents: TaskAgent[] = []; + const saveDraft = () => { + if (!selectedId || mode !== "detail") return; + const editedOwner = detail + ? owner.value !== detail.task.ownerAgentId + ? owner.value + : undefined + : drafts.get(selectedId)?.owner; + const saved = drafts.set(selectedId, { + input: input.value, + ...(editedOwner ? { owner: editedOwner } : {}), + }); + const storageError = + "작업 초안을 저장하지 못했습니다. 내용을 복사하거나 저장소를 확인해주세요."; + if (!saved && !options.document) errorNode.textContent = storageError; + else if (saved && errorNode.textContent === storageError) + errorNode.textContent = ""; + }; + const listView = createTaskList({ + document: doc, + list, + status, + onOpen: (id, opener) => { + returnFocus = opener; + return openTask(id); + }, + onScope: (next) => setScope(next), + }); openLink.setAttribute("target", "_blank"); openLink.setAttribute("rel", "noopener noreferrer"); openLink.textContent = "Codex에서 열기"; @@ -388,15 +422,18 @@ export function installTasks(options: { const current = detail?.task; const running = !!current && RUNNING.has(current.status); add.disabled = busy; - reload.disabled = busy; + reload.disabled = listLoading; createSubmit.disabled = busy; - send.disabled = busy || mode !== "detail" || !input.value.trim(); + send.disabled = + busy || !current || mode !== "detail" || !input.value.trim(); interrupt.disabled = busy || !running; handover.disabled = busy || !current; for (const node of approvals.querySelectorAll("button")) node.disabled = busy; }; const showDialog = () => { + detailVisible = true; + dialog.hidden = false; if (dialog.open) return; if (dialog.showModal) dialog.showModal(); else dialog.open = true; @@ -427,46 +464,50 @@ export function installTasks(options: { } reload.focus(); }; + const revealList = () => { + const surface = (doc.getElementById("task-pane") ?? + doc.getElementById("tasks-dialog")) as TaskNode | null; + if (surface) { + surface.hidden = false; + if (surface.showModal && !surface.open) surface.showModal(); + } + options.onShowList?.(); + }; const dismiss = () => { + if (!detailVisible) return; + detailVisible = false; + saveDraft(); generation++; listSeq++; detailSeq++; busy = false; + listLoading = false; + dialog.hidden = true; controls(); + revealList(); + renderList(); restoreFocus(); }; const hideDialog = () => { - if (!dialog.open) return; - if (dialog.close) dialog.close(); - else { - dialog.open = false; - dismiss(); - } + if (!detailVisible) return; + if (dialog.open && dialog.close) dialog.close(); + else dialog.open = false; + dismiss(); }; - const renderList = () => { - const signature = JSON.stringify( - tasks.map((task) => [task.id, task.title, task.status]), + const renderList = () => + listView.render( + tasks, + ownerAgentId, + scope, + agents, + dialog.open ? selectedId : null, ); - if (signature === listSignature) return; - listSignature = signature; - const active = (doc as SettingsDocument & { activeElement?: TaskNode }) - .activeElement; - const focusId = active && list.contains(active) ? active.id : undefined; - list.replaceChildren(); - for (const task of tasks) { - const button = doc.createElement("button"); - button.type = "button"; - button.id = `task-open-${task.id}`; - button.className = "task-link sidebar-label"; - button.textContent = `${task.title} · ${taskStatusLabel(task.status)}`; - button.setAttribute("aria-label", task.title); - button.addEventListener("click", () => { - returnFocus = button; - return openTask(task.id); - }); - list.append(button); + const fetchAgents = async () => { + try { + return parseTaskAgents(await request("/api/agents")); + } catch { + return agents; } - if (focusId) doc.getElementById(focusId)?.focus(); }; const renderHistory = (thread: TaskThread | null) => { history.replaceChildren(); @@ -512,7 +553,10 @@ export function installTasks(options: { } heading.textContent = current.title; taskStatus.textContent = taskStatusLabel(current.status); - meta.textContent = `${current.ownerAgentId} · ${current.cwd} · ${current.model} · r${current.revision}`; + const ownerName = + agents.find((agent) => agent.id === current.ownerAgentId)?.name ?? + "담당 미지정"; + meta.textContent = `${ownerName} · ${current.cwd} · ${current.model} · r${current.revision}`; const href = current.threadId ? taskDeepLink(current.threadId) : ""; openLink.hidden = !href; openLink.setAttribute("href", href); @@ -542,8 +586,8 @@ export function installTasks(options: { const button = doc.createElement("button"); button.type = "button"; button.className = - decision === "accept" || decision === "decline" - ? "secondary-button" + decision === "accept" + ? "secondary-button primary-button" : "text-button"; button.textContent = label; button.disabled = busy; @@ -557,59 +601,40 @@ export function installTasks(options: { approvals.append(card); } }; - const loadAgents = async (selected: string) => { + const loadAgents = (selected: string, draftOwner?: string) => { owner.replaceChildren(); - let rows: Array<{ id: string; name: string }> = [ - { id: options.ownerAgentId, name: options.ownerAgentId }, - ]; - try { - const value = asRecord(await request("/api/agents")); - const agents = value?.["agents"]; - if (Array.isArray(agents)) - rows = agents.flatMap((item) => { - const rec = asRecord(item); - if (!rec) return []; - const id = rec["id"]; - if (typeof id !== "string" || !/^[a-z][a-z0-9-]{0,47}$/.test(id)) - return []; - const name = rec["name"]; - return [ - { - id, - name: typeof name === "string" && name.trim() ? name : id, - }, - ]; - }); - } catch { - /* Keep the current owner so handover remains possible. */ - } - if (!rows.some((row) => row.id === selected)) - rows.unshift({ id: selected, name: selected }); + const rows = agents.some((row) => row.id === selected) + ? [...agents] + : [{ id: selected, name: selected }, ...agents]; + if (draftOwner && !rows.some((row) => row.id === draftOwner)) + rows.push({ id: draftOwner, name: draftOwner }); for (const row of rows) { const option = doc.createElement("option"); option.value = row.id; option.textContent = row.name; owner.append(option); } - owner.value = selected; + owner.value = draftOwner ?? selected; }; const refresh = async (quiet = false) => { const mine = ++listSeq; if (!quiet) { - generation++; - busy = true; + listLoading = true; controls(); status.textContent = "작업 목록을 불러오는 중…"; } try { - const next = parseTaskList(await request("/api/tasks")); - if (mine !== listSeq) return; - tasks = next; - renderList(); - if (!quiet || !tasks.length) - status.textContent = tasks.length - ? "" - : "작업이 없습니다. 새 작업을 추가하세요."; + const [value, directory] = await Promise.all([ + request("/api/tasks"), + fetchAgents(), + ]); + if (mine !== listSeq || stopped) return; + tasks = parseTaskList(value); + agents = directory; + const count = renderList(); + status.textContent = count + ? "" + : "작업이 없습니다. 새 작업을 추가하세요."; } catch (error) { if (mine !== listSeq) return; if (quiet) return; @@ -617,13 +642,16 @@ export function installTasks(options: { renderList(); status.textContent = message(error, "작업 목록을 불러오지 못했습니다."); } finally { - if (!quiet && mine === listSeq) { - busy = false; + if (mine === listSeq) { + listLoading = false; controls(); } } }; const openCreate = () => { + saveDraft(); + busy = false; + input.value = ""; generation++; detailSeq++; mode = "create"; @@ -640,24 +668,33 @@ export function installTasks(options: { title.focus(); }; const openTask = async (id: string) => { + if (stopped || !TASK_ID.test(id)) return; + saveDraft(); const mine = ++generation; const load = ++detailSeq; busy = true; mode = "detail"; selectedId = id; + input.value = drafts.get(id)?.input ?? ""; + owner.replaceChildren(); detail = null; renderDetail(); errorNode.textContent = ""; taskStatus.textContent = "불러오는 중…"; showDialog(); + renderList(); + options.onOpen?.(id); controls(); try { - const next = parseTaskDetail( - await request(`/api/tasks/${encodeURIComponent(id)}`), - ); + const [value, directory] = await Promise.all([ + request(`/api/tasks/${encodeURIComponent(id)}`), + fetchAgents(), + ]); + const next = parseTaskDetail(value); if (mine !== generation || load !== detailSeq) return; detail = next; - await loadAgents(next.task.ownerAgentId); + agents = directory; + loadAgents(next.task.ownerAgentId, drafts.get(id)?.owner); if (mine !== generation || load !== detailSeq) return; renderDetail(); } catch (error) { @@ -683,6 +720,10 @@ export function installTasks(options: { const ownerEdited = !!detail && ownerDraft !== detail.task.ownerAgentId; detail = { task, thread }; selectedId = task.id; + loadAgents( + task.ownerAgentId, + preserveDraft && ownerEdited && ownerDraft ? ownerDraft : undefined, + ); tasks = tasks.map((item) => (item.id === task.id ? task : item)); if (!tasks.some((item) => item.id === task.id)) tasks = [task, ...tasks]; renderList(); @@ -764,7 +805,7 @@ export function installTasks(options: { requestId: string; model?: string; } = { - ownerAgentId: options.ownerAgentId, + ownerAgentId, title: nextTitle, cwd: nextCwd, prompt: nextPrompt, @@ -778,7 +819,8 @@ export function installTasks(options: { detail = created; mode = "detail"; selectedId = created.task.id; - await loadAgents(created.task.ownerAgentId); + loadAgents(created.task.ownerAgentId); + options.onOpen?.(created.task.id); if (mine !== generation) return; tasks = [ created.task, @@ -821,6 +863,7 @@ export function installTasks(options: { applyDetail(value); if (mine !== generation) return; input.value = ""; + drafts.delete(current.id); } catch (error) { if (mine !== generation) return; errorNode.textContent = message(error, "메시지를 보내지 못했습니다."); @@ -923,16 +966,51 @@ export function installTasks(options: { } } }; + const showList = async () => { + if (stopped) return; + if (dialog.open) hideDialog(); + else revealList(); + await refresh(); + }; + const changeScope = async () => { + saveDraft(); + generation++; + listSeq++; + detailSeq++; + busy = false; + listLoading = false; + if (dialog.open) hideDialog(); + renderList(); + options.onScopeChange?.(scope, ownerAgentId); + await refresh(); + }; + const setOwner = async (id: string) => { + if (stopped || id === ownerAgentId) return; + ownerAgentId = id; + await changeScope(); + }; + const setScope = async (next: TaskListScope) => { + if (stopped || next === scope) return; + scope = next; + drafts.setScope(next); + await changeScope(); + }; add.addEventListener("click", openCreate); reload.addEventListener("click", () => void refresh()); createSubmit.addEventListener("click", () => void createTask()); send.addEventListener("click", () => void sendMessage()); interrupt.addEventListener("click", () => void interruptTask()); handover.addEventListener("click", () => void handoverTask()); - input.addEventListener("input", () => controls()); + input.addEventListener("input", () => { + saveDraft(); + controls(); + }); + owner.addEventListener("change", saveDraft); const closer = doc.getElementById("task-close"); closer?.addEventListener("click", () => hideDialog()); - dialog.addEventListener("close", () => dismiss()); + dialog.addEventListener("close", () => { + if (!dialog.open) dismiss(); + }); createForm.addEventListener("submit", (event) => { event.preventDefault(); void createTask(); @@ -963,7 +1041,17 @@ export function installTasks(options: { return { refresh, sync, + setOwner, + setScope, + showList, + open: openTask, + closeDetail: hideDialog, + canLeave() { + saveDraft(); + return drafts.flush(); + }, close() { + saveDraft(); closeLive(); }, }; diff --git a/packages/lina-web/client/tasks.css b/packages/lina-ui/client/tasks.css similarity index 50% rename from packages/lina-web/client/tasks.css rename to packages/lina-ui/client/tasks.css index f813fb4..6a7f87b 100644 --- a/packages/lina-web/client/tasks.css +++ b/packages/lina-ui/client/tasks.css @@ -1,5 +1,18 @@ .tasks-nav { - margin-top: 28px; + min-width: 0; +} +.task-scope { + display: block; + width: calc(100% - 16px); + min-width: 0; + margin: 4px 8px 0; + padding: 8px; + border: 1px solid var(--line-strong); + border-radius: var(--control-radius); + background: var(--input-bg); + color: var(--text); + font: inherit; + font-size: var(--font-ui); } .tasks-heading { display: flex; @@ -11,7 +24,7 @@ } #tasks-status { margin: 8px 10px 0; - font-size: 0.74rem; + font-size: var(--font-caption); line-height: 1.6; color: var(--muted); word-break: keep-all; @@ -30,16 +43,36 @@ .task-link { display: block; width: 100%; - padding: 10px; + padding: 12px; border: 0; - border-radius: 8px; + border-radius: var(--control-radius); background: transparent; color: var(--text); text-align: left; - font-size: 0.82rem; + font-size: var(--font-ui); line-height: 1.45; overflow-wrap: anywhere; } +.task-link-title, +.task-link-meta { + display: block; +} +.task-link-title { + font-weight: 500; +} +.task-link-meta { + margin-top: 4px; + color: var(--muted); + font-size: var(--font-caption); +} +.task-link[aria-current="true"] { + background: var(--selection); +} +.task-link:focus-visible, +.task-scope:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} .task-link:hover, .task-link:focus-visible { background: var(--surface-raised); @@ -51,7 +84,7 @@ .task-body { display: grid; gap: 12px; - padding: 16px 20px 22px; + padding: 20px 24px 24px; overflow-y: auto; } .task-meta, @@ -64,8 +97,8 @@ } .task-history { display: grid; - gap: 10px; - padding-top: 8px; + gap: 20px; + padding: 16px 0; border-top: 1px solid var(--line); } .task-row { @@ -76,6 +109,11 @@ } .task-row-user { color: var(--text); + background: var(--surface-raised); + border-radius: 16px; + padding: 10px 14px; + margin-left: auto; + max-width: 90%; } .task-row-assistant { color: var(--text); @@ -97,7 +135,7 @@ .task-chat label { display: grid; gap: 6px; - font-size: 0.82rem; + font-size: var(--font-ui); } .task-create input, .task-create textarea, @@ -106,31 +144,82 @@ width: 100%; min-width: 0; border: 1px solid var(--input-border); - border-radius: 8px; + border-radius: var(--control-radius); background: var(--input-bg); color: var(--text); font: inherit; - padding: 9px; + padding: 8px 10px; + min-height: 36px; } .task-chat-actions { display: flex; + justify-content: flex-end; flex-wrap: wrap; gap: 8px; } +.task-composer { + padding: 12px; + border: 1px solid transparent; + border-radius: var(--composer-radius); + background: var(--surface-raised); +} +.task-composer:focus-within { + border-color: var(--line-strong); +} +.task-composer #task-input { + border: 0; + padding: 0 4px 8px; + background: transparent; +} +.task-owner-row { + display: flex; + align-items: flex-end; + gap: 12px; + margin-top: 8px; +} +.task-owner-row label { + min-width: 0; + flex: 1; +} +.task-owner-row button { + flex-shrink: 0; +} #task-open-codex[hidden], #task-create[hidden], #task-chat[hidden] { display: none; } @media (min-width: 768px) { - :root[data-sidebar-collapsed="true"] #task-list, - :root[data-sidebar-collapsed="true"] #tasks-status, - :root[data-sidebar-collapsed="true"] #task-reload { + :root[data-sidebar-collapsed="true"] .sidebar #task-list, + :root[data-sidebar-collapsed="true"] .sidebar #tasks-status, + :root[data-sidebar-collapsed="true"] .sidebar #task-reload, + :root[data-sidebar-collapsed="true"] .sidebar #task-scope { display: none; } + #task-column .tasks-heading .sidebar-label, + #task-column #task-reload, + #task-column #tasks-status:not(:empty) { + display: revert; + } } @media (max-width: 767px) { + .task-dialog { + width: 100%; + max-width: 100%; + height: 100dvh; + max-height: 100dvh; + margin: 0; + border: 0; + border-radius: 0; + } + .task-dialog .dialog-header { + padding-top: max(16px, env(safe-area-inset-top)); + } + .task-body { + padding-bottom: max(22px, env(safe-area-inset-bottom)); + } .task-link, + .task-scope, .task-chat-actions button, .task-create button { min-height: 44px; @@ -145,8 +234,9 @@ display: grid; gap: 8px; padding: 12px; - border: 1px solid var(--line-strong); - border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-sidebar); + border-radius: 16px; } .task-approval-details { margin: 0; @@ -160,6 +250,7 @@ } .task-approval-actions { display: flex; + justify-content: flex-end; flex-wrap: wrap; gap: 8px; } @@ -168,3 +259,10 @@ min-height: 44px; } } + +@media (max-width: 767px) { + .task-create input, + .task-chat select { + min-height: 44px; + } +} diff --git a/packages/lina-web/client/theme-boot.ts b/packages/lina-ui/client/theme-boot.ts similarity index 100% rename from packages/lina-web/client/theme-boot.ts rename to packages/lina-ui/client/theme-boot.ts diff --git a/packages/lina-web/client/theme-controls.ts b/packages/lina-ui/client/theme-controls.ts similarity index 51% rename from packages/lina-web/client/theme-controls.ts rename to packages/lina-ui/client/theme-controls.ts index df647b1..a6779bd 100644 --- a/packages/lina-web/client/theme-controls.ts +++ b/packages/lina-ui/client/theme-controls.ts @@ -1,24 +1,27 @@ -import { readTheme, resolveTheme, saveTheme } from "./theme.ts"; +import { createElement } from "react"; +import { mountView } from "./components/mount.ts"; +import { ThemeControlsView } from "./components/theme-controls.tsx"; +import { readTheme, resolveTheme, saveTheme, type Theme } from "./theme.ts"; + export function installThemeControls(failed: () => void = () => {}): void { - const buttons = document.querySelectorAll( - "[data-theme-choice]", - ); let storage: Storage | undefined; try { storage = window.localStorage; } catch {} let preference = readTheme(storage); const system = matchMedia("(prefers-color-scheme: dark)"); + // Each host keeps its accessible label and attributes; React owns its choices. + const views = Array.from( + document.querySelectorAll(".theme-switch"), + mountView, + ); const apply = () => { document.documentElement.dataset["theme"] = resolveTheme( preference, system.matches, ); - for (const button of buttons) - button.setAttribute( - "aria-pressed", - String(button.dataset["themeChoice"] === preference), - ); + for (const view of views) + view.render(createElement(ThemeControlsView, { preference, select })); document .querySelector('meta[name="theme-color"]') ?.setAttribute( @@ -28,13 +31,11 @@ export function installThemeControls(failed: () => void = () => {}): void { .trim(), ); }; - for (const button of buttons) - button.addEventListener("click", () => { - const next = button.dataset["themeChoice"]; - preference = next === "light" || next === "system" ? next : "dark"; - apply(); - if (!saveTheme(storage, preference)) failed(); - }); + const select = (next: Theme) => { + preference = next; + apply(); + if (!saveTheme(storage, preference)) failed(); + }; system.addEventListener("change", () => { if (preference === "system") apply(); }); diff --git a/packages/lina-web/client/theme.ts b/packages/lina-ui/client/theme.ts similarity index 100% rename from packages/lina-web/client/theme.ts rename to packages/lina-ui/client/theme.ts diff --git a/packages/lina-ui/client/tokens.css b/packages/lina-ui/client/tokens.css new file mode 100644 index 0000000..e040068 --- /dev/null +++ b/packages/lina-ui/client/tokens.css @@ -0,0 +1,69 @@ +:root { + color-scheme: dark; + --space-unit: 0.25rem; + --control-radius: 8px; + --control-size: 32px; + --icon-size: 18px; + --font-ui: 0.875rem; + --font-caption: 0.75rem; + --composer-radius: 22px; + --conversation-width: 832px; + --menu-radius: 16px; + --app-bg: #181818; + --surface-main: #181818; + --surface-sidebar: #212121; + --surface-inspector: #212121; + --surface-raised: #2b2b2b; + --input-bg: #2b2b2b; + --input-border: #626262; + --text: #ededed; + --muted: #a3a3a3; + --line: #ffffff0f; + --line-strong: #555555; + --accent: #dddddd; + --accent-hover: #ffffff; + --selection: #ffffff0f; + --hover: #ffffff09; + --button-bg: #eeeeee; + --button-text: #242424; + --button-hover: #ffffff; + --disabled-bg: #393939; + --success: #a0c6ad; + --warning: #e8b680; + --danger: #f0a5a1; + --focus-ring: #dddddd20; + --tint: var(--surface-raised); + --rail-text: var(--muted); +} +:root[data-theme="light"] { + color-scheme: light; + --app-bg: #eeeeee; + --surface-main: #ffffff; + --surface-sidebar: #f5f5f5; + --surface-inspector: #fafafa; + --surface-raised: #eeeeee; + --input-bg: #ffffff; + --input-border: #858585; + --text: #242424; + --muted: #666666; + --line: #e2e2e2; + --line-strong: #b5b5b5; + --accent: #303030; + --accent-hover: #101010; + --selection: #e6e6e6; + --hover: #00000006; + --button-bg: #242424; + --button-text: #ffffff; + --button-hover: #101010; + --disabled-bg: #e6e6e6; + --success: #326649; + --warning: #875922; + --danger: #a03935; + --focus-ring: #24242418; +} +@media (max-width: 767px) { + :root { + --control-size: 44px; + --icon-size: 20px; + } +} diff --git a/packages/lina-web/client/tool-label.ts b/packages/lina-ui/client/tool-label.ts similarity index 100% rename from packages/lina-web/client/tool-label.ts rename to packages/lina-ui/client/tool-label.ts diff --git a/packages/lina-web/client/tool-view.ts b/packages/lina-ui/client/tool-view.ts similarity index 97% rename from packages/lina-web/client/tool-view.ts rename to packages/lina-ui/client/tool-view.ts index 2c795f7..4703ae2 100644 --- a/packages/lina-web/client/tool-view.ts +++ b/packages/lina-ui/client/tool-view.ts @@ -1,4 +1,4 @@ -import type { ChatMessage } from "./model.ts"; +import type { ChatMessage } from "../../lina-client/src/model.ts"; import { toolSummary } from "./tool-label.ts"; /** Structural subset of DOM elements used here; real DOM elements satisfy it. */ diff --git a/packages/lina-ui/client/tsconfig.json b/packages/lina-ui/client/tsconfig.json new file mode 100644 index 0000000..5cf4b41 --- /dev/null +++ b/packages/lina-ui/client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": ["./**/*.ts", "./**/*.tsx", "../../lina-client/src/*.ts"], + "exclude": [] +} diff --git a/packages/lina-ui/package.json b/packages/lina-ui/package.json new file mode 100644 index 0000000..582795f --- /dev/null +++ b/packages/lina-ui/package.json @@ -0,0 +1,16 @@ +{ + "name": "@lina/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "dependencies": { + "@base-ui/react": "1.8.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7" + } +} diff --git a/packages/lina-web/DESIGN.md b/packages/lina-web/DESIGN.md index 96f4c89..7c9e700 100644 --- a/packages/lina-web/DESIGN.md +++ b/packages/lina-web/DESIGN.md @@ -1,6 +1,6 @@ # Lina visual direction -Codex 디자인 언어, Electron/웹 공통 렌더러, 메신저형 에이전트 목록과 모바일 탐색은 목표 방향이며 이미 구현된 상태가 아닙니다. 외부 주장은 재검증하지 않았습니다. +공통 화면과 디자인 토큰은 `../lina-ui/client`에 있습니다. 메신저형 목록·모바일 탐색·붙여넣기 큐와 Electron 동봉 빌드를 구현했습니다. 2026-09-07 사용자가 제공한 Codex 앱 화면을 기준으로 표면의 밝기 순서, 버튼 정렬, 메뉴와 입력창을 재검수했습니다. 실제 검증 범위와 남은 플랫폼 연결은 [구현 기록](../../docs/plans/codex-ui/010_implementation.md)을 따릅니다. 특정 Codex 버전과의 픽셀 단위 일치를 보증하지 않습니다. The product direction is to adopt the Codex design language across surfaces, typography, spacing, icons, menus, the @@ -9,7 +9,7 @@ and scale for visual verification. Keep Lina's agent-centric navigation and conv identity. Desktop targets Electron; web/PWA shares the same renderer and tokens. The plan specifies a messenger-style agent list and mobile navigation. Apply Codex styling to photo/name/recent-message rows; use full-screen mobile lists and optional desktop task columns. -This describes the target design; the implementation has not yet been migrated. +Web and desktop builds now consume the same renderer; native platform acceptance is recorded separately. ## First setup and agent creation @@ -32,16 +32,18 @@ This utility surface does not need generated concept art, gradients or ornamenta ## Token ownership -`client/tokens.css` owns color values for both modes. Components use semantic tokens +[`../lina-ui/client/tokens.css`](../lina-ui/client/tokens.css) owns color values for both modes. Components use semantic tokens for main/sidebar/elevated surfaces, text, muted text, borders, focus/selection, buttons and status. This document describes their roles rather than duplicating numeric values. -Dark: charcoal surfaces with enough separation between navigation, conversation and -input; off-white text and readable muted labels. Light: white and light neutral gray +Dark: the conversation is the darkest surface, the sidebar is one step lighter, +and the composer and menus are raised neutral surfaces. Use off-white text and readable muted labels. Light: white and light neutral gray surfaces with black text. Selection uses the Codex neutral surface treatment; focus and semantic status colors remain accessible and are verified separately. Primary send/action buttons use high-contrast neutral treatment. +The composer has a shared horizontal control axis: attachment on the left, model settings and a round arrow send control on the right. Task message input uses the same surface and send control. Icon geometry comes from SVG rather than font glyphs; mobile keeps at least 44px action targets. Form actions align to the end of their group. Mobile settings tabs stay on one line, dropping redundant icons at the narrowest width. Border emphasis belongs to form fields and keyboard focus; ordinary navigation and the chat header remain quiet. + Use platform system fonts with Korean fallbacks, modest font weights, a consistent spacing scale and restrained radii. Avoid simulated window traffic lights or controls without behavior. Existing history, draft recovery and source expansion remain usable. @@ -122,3 +124,28 @@ height follows content; long drafts expose a temporary expand/reduce action. Bou height by the visible viewport and fit complete text rows. Preserve draft/selection, and keep Stop and required approvals reachable. Installation/update descriptions explain the web-app benefit and the explicit apply/reload behavior only in settings. + +### Common component ownership + +`lina-ui/client/components/ui/` is the shared shadcn/Base UI component source. +`controls.css` applies Lina's tokens to Button and DropdownMenu; component classes +retain agent-first geometry. The wrappers adapt the upstream base-nova patterns, +without adding Tailwind or another build pipeline. Base UI owns focus, keyboard +navigation, disabled semantics and floating placement inside the component. + +Imperative controllers mount a view into a dedicated host and pass values/actions. +They must not mutate that host's children. In particular, list notices use the +list view API, menu actions use callbacks, settings panels are controlled separately +from the React tab navigation, and combobox updates use its adapter methods. +Conversation history, composer drafts, attachment queues and transport stay in +existing controllers/models. Adding a component does not change their ownership. + + +### Task layout control + +The top sidebar icon only collapses navigation. Agent/task tabs only choose a +list. The task toolbar owns the labeled `나란히 보기` / `나란히 보기 종료` action, +using the common Button. Show it only when the actual sidebar width plus the task +and reading columns fit. One control opens and closes the extra column; preserve +list nodes, drafts, filters and focus across reparenting. Narrow screens fall back +to the ordinary task list while retaining the user's wide-screen preference. diff --git a/packages/lina-web/client/attachments.ts b/packages/lina-web/client/attachments.ts deleted file mode 100644 index cdc464c..0000000 --- a/packages/lina-web/client/attachments.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - AttachmentDraft, - attachmentHref, - type DraftAttachment, -} from "./attachment-draft.ts"; -import { element, setText } from "./render.ts"; -export function createAttachments( - input: HTMLTextAreaElement, - session: () => string | undefined, - changed: () => void, - notice: (text: string) => void, -) { - const draft = new AttachmentDraft(); - const list = element("draft-files", HTMLDivElement); - const picker = element("file-picker", HTMLInputElement), - button = element("attach-file", HTMLButtonElement); - let uploading = false, - connected = false, - signature = ""; - const render = () => { - const next = JSON.stringify([session(), draft.refs]); - if (signature !== next) { - signature = next; - list.replaceChildren(); - list.hidden = !draft.refs.length; - for (const ref of draft.refs) { - const row = fileChip(ref, session()); - const remove = document.createElement("button"); - remove.type = "button"; - remove.textContent = "×"; - remove.setAttribute("aria-label", `${ref.name} 첨부 제외`); - remove.addEventListener("click", () => { - draft.remove(ref.id); - render(); - changed(); - }); - row.append(remove); - list.append(row); - } - } - button.disabled = !connected || uploading || draft.refs.length >= 4; - setText(button, uploading ? "첨부 중…" : "파일 첨부"); - }; - const port = { - get value() { - draft.text = input.value; - return draft.value; - }, - set value(text: string) { - draft.value = text; - input.value = draft.text; - render(); - }, - }; - button.addEventListener("click", () => picker.click()); - picker.addEventListener("change", () => { - const files = Array.from(picker.files ?? []); - picker.value = ""; - if (!files.length) return; - if (!connected || uploading) return; - if (files.length + draft.refs.length > 4) { - notice("첨부 한도 초과 · 최대 4개"); - return; - } - const owner = session(); - if (!owner) return; - uploading = true; - render(); - changed(); - void (async () => { - for (const file of files) { - try { - if (file.size > 2097152) throw new Error("파일 크기 초과 · 최대 2MB"); - if (!connected || session() !== owner) - throw new Error("연결 확인 후 파일 다시 선택"); - const response = await fetch("/api/attachments", { - method: "POST", - headers: { - "X-Lina-Session": owner, - "X-Lina-Filename": encodeURIComponent(file.name), - "Content-Type": "application/octet-stream", - }, - body: file, - signal: AbortSignal.timeout(30000), - redirect: "error", - }); - if (!response.ok) { - if (response.status === 413) - throw new Error("파일 크기 초과 · 최대 2MB"); - if (response.status >= 500 && response.status !== 507) - throw new Error("첨부 저장소 오류 · 서버 상태 확인 필요"); - if (response.status === 507) throw new Error("첨부 저장 공간 부족"); - throw new Error( - "첨부 실패 · 텍스트·이미지·PDF·DOCX·XLSX 형식과 파일명 확인", - ); - } - const meta: unknown = await response.json(); - if ( - typeof meta !== "object" || - !meta || - !("id" in meta) || - typeof meta.id !== "string" || - !("name" in meta) || - typeof meta.name !== "string" || - session() !== owner - ) - throw new Error("첨부 결과 미확인 · 자동 재전송 안 함"); - draft.text = input.value; - draft.add({ id: meta.id, name: meta.name, sessionId: owner }); - render(); - changed(); - } catch (error) { - notice(error instanceof Error ? error.message : "첨부 결과 미확인"); - break; - } - } - })().finally(() => { - uploading = false; - render(); - changed(); - }); - }); - return { - port, - get busy() { - return uploading; - }, - update(online: boolean) { - connected = online; - render(); - }, - }; -} -function fileChip( - ref: DraftAttachment, - sessionId: string | undefined, -): HTMLElement { - const row = document.createElement("span"); - row.className = "file-chip"; - if (ref.sessionId !== sessionId) { - row.textContent = `${ref.name} · ${sessionId ? "다른 대화의 첨부" : "연결 후 확인"}`; - return row; - } - const link = document.createElement("a"); - link.href = attachmentHref(ref); - link.textContent = ref.name; - link.title = ref.name; - link.download = ref.name; - const preview = document.createElement("button"); - preview.type = "button"; - preview.textContent = "미리보기"; - preview.addEventListener("click", () => void showPreview(ref)); - row.append(link, preview); - return row; -} -export function renderUserBody( - container: Element, - text: string, - sessionId: string | undefined, -): void { - const draft = new AttachmentDraft(); - draft.value = text; - container.replaceChildren(document.createTextNode(draft.text)); - if (draft.refs.length) { - const files = document.createElement("div"); - files.className = "message-files"; - files.append(...draft.refs.map((ref) => fileChip(ref, sessionId))); - container.append(files); - } -} -async function showPreview(ref: DraftAttachment): Promise { - const dialog = document.createElement("dialog"); - dialog.className = "file-preview"; - dialog.setAttribute("aria-label", `${ref.name} 미리보기`); - const title = document.createElement("strong"); - title.textContent = ref.name; - const close = document.createElement("button"); - close.type = "button"; - close.textContent = "닫기"; - close.addEventListener("click", () => dialog.close()); - const body = document.createElement("div"); - body.textContent = "불러오는 중"; - dialog.append(title, close, body); - document.body.append(dialog); - dialog.addEventListener("close", () => dialog.remove(), { once: true }); - dialog.showModal(); - try { - const response = await fetch(attachmentHref(ref, "/preview"), { - signal: AbortSignal.timeout(10000), - redirect: "error", - }); - if (!response.ok) throw new Error("미리보기 실패"); - const mime = response.headers.get("Content-Type") ?? ""; - if (mime.startsWith("image/png") || mime.startsWith("image/jpeg")) { - const image = document.createElement("img"); - image.alt = ref.name; - // Same-origin URL, not a data/blob URL; server validates format and dimensions. - image.src = attachmentHref(ref, "/preview"); - image.addEventListener( - "error", - () => { - body.textContent = "이미지 표시 실패 · 다운로드 가능"; - }, - { once: true }, - ); - body.replaceChildren(image); - await response.body?.cancel(); - } else if (mime.startsWith("text/plain")) { - const text = await response.text(); - const pre = document.createElement("pre"); - pre.textContent = - text.slice(0, 8192) + - (text.length > 8192 ? "\n… 전체 내용은 다운로드로 확인" : ""); - body.replaceChildren(pre); - } else { - await response.body?.cancel(); - throw new Error("미리보기 미지원"); - } - } catch (error) { - body.textContent = error instanceof Error ? error.message : "미리보기 실패"; - } -} diff --git a/packages/lina-web/client/composer.css b/packages/lina-web/client/composer.css deleted file mode 100644 index 85d2a65..0000000 --- a/packages/lina-web/client/composer.css +++ /dev/null @@ -1,88 +0,0 @@ -.composer-area { - display: flex; - flex-direction: column; - min-height: 0; - padding: 10px 30px 18px; - width: 100%; - max-width: 820px; - align-self: center; -} -.composer { - flex-shrink: 0; - border: 1px solid var(--input-border); - border-radius: 16px; - padding: 15px 15px 12px; - background: var(--input-bg); - box-shadow: none; -} -.composer:focus-within { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--focus-ring); -} -textarea { - display: block; - width: 100%; - border: none; - outline: none; - resize: none; - min-height: 44px; - max-height: none; - background: transparent; - color: inherit; - font-size: 0.86rem; - line-height: 1.7; -} -textarea:focus-visible { - outline: none; -} -textarea::placeholder { - color: var(--muted); -} -.composer-bottom { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; - margin-top: 6px; -} -#composer-hint { - font-size: 0.65rem; - color: var(--muted); - line-height: 1.5; -} -#send { - border: 0; - border-radius: 8px; - padding: 10px 14px; - color: var(--button-text); - background: var(--button-bg); - min-height: 36px; - white-space: nowrap; - font-size: 0.75rem; -} -#send span { - margin-left: 10px; - font-size: 0.95rem; -} -#send:hover:not(:disabled) { - background: var(--button-hover); -} -#send:disabled { - background: var(--disabled-bg); - color: var(--muted); -} - -.work-panels { - min-height: 0; - max-height: 50dvh; - overflow: auto; -} -.execution-bar { - flex-shrink: 0; -} - -.composer-expand { - margin-left: auto; - color: var(--muted); - white-space: nowrap; -} diff --git a/packages/lina-web/client/conversation-app.ts b/packages/lina-web/client/conversation-app.ts deleted file mode 100644 index a71512b..0000000 --- a/packages/lina-web/client/conversation-app.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { createAgents } from "./agents.ts"; -import { createAttachments } from "./attachments.ts"; -import { createComposerSizing } from "./composer-size.ts"; -import { Connection } from "./connection.ts"; -import { ContextModel } from "./context-model.ts"; -import { browserDraftStore } from "./draft.ts"; -import { DurableChatModel } from "./durable-model.ts"; -import { ExecutionModel } from "./execution-model.ts"; -import { createExecutionView } from "./execution-view.ts"; -import { createNavigation } from "./navigation.ts"; -import { shouldSend } from "./preferences.ts"; -import { activityStatus, elapsedText } from "./presence.ts"; -import { installPwa } from "./pwa.ts"; -import { PendingRecovery } from "./recovery.ts"; -import { createRenderer, element, setText } from "./render.ts"; -import { createSearch } from "./search-view.ts"; -import { createSettings } from "./settings.ts"; -import { installTasks } from "./task-view.ts"; - -export function startConversationApp(initialNotice = "") { - const settings = createSettings(); - const queryAgent = new URL(location.href).searchParams.get("agent") ?? "lina"; - const agentId = /^[a-z][a-z0-9-]{0,47}$/.test(queryAgent) - ? queryAgent - : "lina"; - - const model = new DurableChatModel(); - const execution = new ExecutionModel(); - const context = new ContextModel(); - const drafts = browserDraftStore(() => window.localStorage, agentId); - let subscribing = false; - const input = element("message", HTMLTextAreaElement); - const send = element("send", HTMLButtonElement); - const composerSizing = createComposerSizing(input); - const notice = element("notice", HTMLDivElement); - const reconnect = element("reconnect", HTMLButtonElement); - const earlier = element("earlier", HTMLButtonElement); - const latest = element("latest", HTMLButtonElement); - let detail = initialNotice; - const attachments = createAttachments( - input, - () => model.sessionId, - () => { - recovery.edited(); - drafts.saveDraft(attachments.port.value); - update(); - }, - (message) => { - detail = message; - update(); - }, - ); - const textPort = attachments.port; - const restore = (text: string): void => { - textPort.value = text; - recovery.edited(); - drafts.saveDraft(text); - input.focus(); - if (text.length > 16000) detail = "메시지 길이 초과 · 최대 16,000자"; - update(); - }; - const recovery = new PendingRecovery(drafts, textPort, restore, (message) => { - detail = message; - }); - const render = createRenderer( - model, - restore, - (id) => { - const message = model.messages.find((item) => item.id === id); - if ( - model.sessionId && - message?.sourceId && - typeof message.nextOffset === "number" - ) - connection.command({ - type: "entry", - sessionId: model.sessionId, - entryId: message.sourceId, - offset: message.nextOffset, - }); - }, - () => model.sessionId, - ); - - const renderExecution = createExecutionView( - execution, - (frame) => connection.command(frame), - update, - ); - - const search = createSearch((frame) => connection.command(frame)); - const tasks = installTasks({ ownerAgentId: agentId }); - void tasks.refresh(); - createAgents(agentId, () => { - if ( - attachments.busy || - (textPort.value && !drafts.saveDraft(textPort.value)) - ) { - detail = "초안을 저장한 뒤 에이전트를 바꿔주세요."; - update(); - return false; - } - return true; - }); - const renderNavigation = createNavigation(settings, (opener) => - search.open(opener), - ); - - const presence = element("presence", HTMLDivElement); - const presenceLabel = element("presence-label", HTMLSpanElement); - const elapsed = element("presence-elapsed", HTMLSpanElement); - let elapsedTimer: ReturnType | undefined; - function renderPresence(): void { - if (elapsedTimer) clearTimeout(elapsedTimer); - const status = activityStatus({ - connected: model.connected, - running: model.running, - pending: model.pendingId !== undefined, - requestStartedAt: model.requestStartedAt( - execution.state?.cancelRequestId, - ), - control: execution.connected ? execution.state : undefined, - contextBusy: context.state?.busy === true, - }); - presence.hidden = !status; - presence.dataset["moving"] = String(status?.moving === true); - setText(presenceLabel, status?.label ?? ""); - const time = elapsedText(status?.startedAt, Date.now()); - setText(elapsed, time ? `· ${time}` : ""); - if (status?.moving && status.startedAt) - elapsedTimer = setTimeout(renderPresence, 1000); - } - - function update(): void { - composerSizing.update(); - attachments.update(model.connected); - render(); - renderNavigation(model); - search.connect(model.connected, model.sessionId); - renderExecution(); - context.connected = model.connected; - context.running = model.running; - - element("welcome", HTMLElement).hidden = - model.messages.length > 0 || model.running; - send.disabled = - !model.connected || - attachments.busy || - !model.sessionId || - model.pendingId !== undefined || - execution.state?.cancelling === true || - context.state?.busy === true || - textPort.value.length > 16000 || - textPort.value.trim().length === 0; - const state = element("connection-status", HTMLSpanElement); - setText(state, model.connected ? "연결됨" : "연결 대기"); - state.hidden = model.connected; - renderPresence(); - state.className = `connection-status${model.connected ? " online" : ""}`; - element("agent-dot", HTMLSpanElement).className = - `agent-dot${model.connected ? " online" : ""}`; - reconnect.hidden = model.connected; - const shownDetail = - textPort.value.length > 16000 - ? "메시지 길이 초과 · 최대 16,000자" - : detail; - notice.hidden = shownDetail.length === 0; - setText(notice, shownDetail); - earlier.hidden = !model.hasEarlier; - earlier.disabled = !model.connected; - latest.hidden = !model.hasNewer; - latest.disabled = !model.connected; - } - - const connection = new Connection( - `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws${agentId === "lina" ? "" : `?agent=${agentId}`}`, - { - frame(frame) { - if (frame.type === "agent-status" && !subscribing) { - subscribing = true; - connection.command({ type: "subscribe", version: 2 }); - } - search.receive(frame); - model.receive(frame); - if (frame.type === "snapshot") context.bindSession(model.sessionId); - if (frame.type === "context-state" || frame.type === "context-error") - context.receive(frame); - if (frame.type === "snapshot") execution.bindSession(model.sessionId); - if (frame.type === "control-state" || frame.type === "control-error") - execution.receive(frame, model.sessionId); - if (frame.type === "snapshot") detail = ""; - recovery.receive(frame, model.sessionId); - update(); - }, - disconnected() { - subscribing = false; - model.disconnect(); - execution.disconnect(); - context.disconnect(); - recovery.disconnected(); - update(); - }, - notice(message) { - detail = message; - update(); - }, - }, - ); - - element("composer", HTMLFormElement).addEventListener("submit", (event) => { - event.preventDefault(); - const text = textPort.value.trim(); - if (send.disabled || !model.sessionId) return; - const request = recovery.prepare(text, model.sessionId); - if (!request) { - update(); - return; - } - const { id } = request; - if (!model.send(id, text)) return; - recovery.sent(request); - textPort.value = ""; - drafts.saveDraft(""); - if ( - !connection.send({ type: "chat", sessionId: model.sessionId, id, text }) - ) { - model.disconnect(); - textPort.value = text; - drafts.saveDraft(text); - } - update(); - input.focus(); - }); - input.addEventListener("input", () => { - recovery.edited(); - drafts.saveDraft(textPort.value); - update(); - }); - earlier.addEventListener("click", () => { - if (model.sessionId && model.beforeCursor !== null) - connection.command({ - type: "history", - sessionId: model.sessionId, - before: model.beforeCursor, - }); - }); - latest.addEventListener("click", () => { - model.showLatest(); - connection.command({ type: "subscribe", version: 2 }); - const scroll = element("conversation-scroll", HTMLDivElement); - scroll.scrollTop = scroll.scrollHeight; - }); - input.addEventListener("keydown", (event) => { - if (!shouldSend(event, settings.preferences.sendKey)) return; - event.preventDefault(); - if (!send.disabled) element("composer", HTMLFormElement).requestSubmit(); - }); - for (const button of document.querySelectorAll( - "[data-prompt]", - )) { - button.addEventListener("click", () => - restore(button.getAttribute("data-prompt") ?? ""), - ); - } - reconnect.addEventListener("click", () => { - detail = ""; - connection.connect(); - }); - window.addEventListener("pagehide", () => { - connection.stop(); - if (elapsedTimer) clearTimeout(elapsedTimer); - tasks.close(); - }); - window.addEventListener("pageshow", (event) => { - if (event.persisted) connection.resume(); - }); - connection.connect(); - - installPwa(drafts, () => textPort.value); - window.addEventListener("offline", () => { - connection.connect(); - update(); - }); - window.addEventListener("online", () => { - connection.connect(); - update(); - }); -} diff --git a/packages/lina-web/client/model-combobox.ts b/packages/lina-web/client/model-combobox.ts deleted file mode 100644 index 4bf5ed0..0000000 --- a/packages/lina-web/client/model-combobox.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { SettingsDocument, SettingsNode } from "./model-settings.ts"; - -export interface ModelChoice { - value: string; - label: string; - search?: string; - inherited?: boolean; -} -export interface ComboboxBox { - top: number; - bottom: number; -} -export interface ComboboxListFit { - placement: "above" | "below"; - maxHeight: number; -} -type Measurable = { - getBoundingClientRect?: () => ComboboxBox; - closest?: (selector: string) => Measurable | null; - querySelector?: (selector: string) => Measurable | null; - style?: { maxHeight: string }; -}; - -export const COMBOBOX_LIST_GAP = 5; -export const COMBOBOX_LIST_MAX = 220; -const PREFER_BELOW = 120; - -/** Keep the popup inside a clip (dialog/scrollport), flipping above sticky actions when needed. */ -export function fitComboboxList(args: { - input: ComboboxBox; - clip: ComboboxBox; - obstacle?: ComboboxBox | null; - gap?: number; - max?: number; - preferBelow?: number; -}): ComboboxListFit { - const gap = args.gap ?? COMBOBOX_LIST_GAP; - const max = args.max ?? COMBOBOX_LIST_MAX; - const preferBelow = args.preferBelow ?? PREFER_BELOW; - const belowLimit = - args.obstacle && args.obstacle.top < args.clip.bottom - ? Math.min(args.clip.bottom, args.obstacle.top) - : args.clip.bottom; - const below = Math.max(0, belowLimit - args.input.bottom - gap); - const above = Math.max(0, args.input.top - args.clip.top - gap); - const placement = below >= preferBelow || below >= above ? "below" : "above"; - return { - placement, - maxHeight: Math.max( - 0, - Math.min(max, placement === "below" ? below : above), - ), - }; -} - -export function placedComboboxList( - input: ComboboxBox, - fit: ComboboxListFit, - gap = COMBOBOX_LIST_GAP, -): ComboboxBox { - if (fit.placement === "below") { - const top = input.bottom + gap; - return { top, bottom: top + fit.maxHeight }; - } - const bottom = input.top - gap; - return { top: bottom - fit.maxHeight, bottom }; -} - -/** Editable combobox. DOM focus stays on the input while arrows move the active option. */ -export function createModelCombobox( - doc: SettingsDocument, - id: string, - label: string, - change: (value: string) => void, -) { - const root = doc.createElement("div"); - root.className = "model-combobox"; - const input = doc.createElement("input"); - input.id = `${id}-input`; - input.type = "text"; - input.setAttribute("role", "combobox"); - input.setAttribute("aria-label", label); - input.setAttribute("aria-autocomplete", "list"); - input.setAttribute("aria-expanded", "false"); - input.setAttribute("aria-controls", `${id}-list`); - input.setAttribute("autocomplete", "off"); - input.setAttribute("placeholder", "모델 검색…"); - const list = doc.createElement("div"); - list.id = `${id}-list`; - list.className = "model-combobox-list"; - list.setAttribute("role", "listbox"); - list.setAttribute("aria-label", `${label} 목록`); - list.hidden = true; - const empty = doc.createElement("p"); - empty.id = `${id}-empty`; - empty.className = "agent-hint"; - empty.setAttribute("role", "status"); - empty.hidden = true; - root.append(input, list, empty); - list.addEventListener("mousedown", (e) => e.preventDefault()); - let choices: ModelChoice[] = [], - selected = "", - display = "모델 선택…", - opened = false, - query = "", - active = -1; - let matched: ModelChoice[] = [], - nodes: SettingsNode[] = []; - const listStyle = (list as SettingsNode & Measurable).style; - const normalize = (v: string) => - v.trim().replace(/\s+/g, " ").toLocaleLowerCase(); - const highlight = () => { - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (!node) continue; - node.setAttribute("data-active", String(i === active)); - } - const node = nodes[active]; - if (node) { - input.setAttribute("aria-activedescendant", node.id); - node.scrollIntoView?.({ block: "nearest" }); - } else input.removeAttribute("aria-activedescendant"); - }; - const clearPlacement = () => { - root.removeAttribute("data-open"); - root.removeAttribute("data-placement"); - if (listStyle) listStyle.maxHeight = ""; - }; - const place = () => { - if (!opened) return; - root.setAttribute("data-open", "true"); - const host = input as SettingsNode & Measurable; - const inputBox = host.getBoundingClientRect?.(); - const clipEl = - host.closest?.(".settings-content") ?? host.closest?.("dialog"); - const clipBox = clipEl?.getBoundingClientRect?.(); - if (!inputBox || !clipBox) return; - const obstacle = (host.closest?.("dialog") ?? clipEl) - ?.querySelector?.(".model-actions") - ?.getBoundingClientRect?.(); - const fit = fitComboboxList({ - input: inputBox, - clip: clipBox, - obstacle: obstacle ?? null, - }); - root.setAttribute("data-placement", fit.placement); - if (listStyle) listStyle.maxHeight = `${Math.floor(fit.maxHeight)}px`; - }; - const close = () => { - opened = false; - list.hidden = true; - empty.hidden = true; - input.setAttribute("aria-expanded", "false"); - input.removeAttribute("aria-activedescendant"); - input.value = display; - clearPlacement(); - }; - const commit = (item: ModelChoice) => { - selected = item.value; - display = item.label; - close(); - change(item.value); - }; - const render = () => { - const needle = normalize(query); - matched = choices.filter( - (c) => - !needle || - needle - .split(" ") - .every((part) => - normalize(`${c.label} ${c.search ?? ""}`).includes(part), - ), - ); - if (needle) - matched.sort((a, b) => Number(!!a.inherited) - Number(!!b.inherited)); - list.replaceChildren(); - nodes = []; - // Only configured models are supplied; all matches remain keyboard reachable. - for (const [i, item] of matched.entries()) { - const node = doc.createElement("div"); - node.id = `${id}-option-${i}`; - node.setAttribute("role", "option"); - node.setAttribute("aria-selected", String(item.value === selected)); - node.textContent = item.label; - node.addEventListener("mousedown", (e) => e.preventDefault()); - node.addEventListener("click", () => commit(item)); - list.append(node); - nodes.push(node); - } - empty.hidden = matched.length > 0; - empty.textContent = matched.length ? "" : "검색 결과가 없습니다."; - active = -1; - highlight(); - place(); - }; - const open = () => { - if (input.disabled || opened) return; - opened = true; - query = ""; - list.hidden = false; - input.setAttribute("aria-expanded", "true"); - render(); - input.select?.(); - }; - input.addEventListener("focus", open); - input.addEventListener("click", () => { - open(); - input.select?.(); - }); - input.addEventListener("input", () => { - if (input.disabled) return; - query = input.value; - opened = true; - list.hidden = false; - input.setAttribute("aria-expanded", "true"); - render(); - }); - input.addEventListener("keydown", (event) => { - if (event.key === "Escape" && opened) { - event.preventDefault(); - event.stopPropagation(); - close(); - return; - } - if (event.key === "Tab") { - close(); - return; - } - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - event.preventDefault(); - open(); - if (!matched.length) return; - active = - event.key === "ArrowDown" - ? (active + 1) % matched.length - : active <= 0 - ? matched.length - 1 - : active - 1; - highlight(); - return; - } - if (event.key === "Enter" && opened) { - event.preventDefault(); - const item = matched[active < 0 ? 0 : active]; - if (item) commit(item); - } - }); - root.addEventListener("focusout", (e) => { - if (!root.contains(e.relatedTarget)) close(); - }); - const view = globalThis as { - addEventListener?( - type: string, - listener: () => void, - options?: boolean, - ): void; - }; - view.addEventListener?.("resize", place); - view.addEventListener?.("scroll", place, true); - return { - root, - input, - close, - set( - items: ModelChoice[], - value: string, - labelText: string, - disabled = false, - ) { - choices = items; - selected = value; - display = labelText; - input.disabled = disabled; - input.setAttribute("title", labelText); - if (opened) { - render(); - } else input.value = display; - }, - disable(value: boolean) { - input.disabled = value; - if (value) close(); - }, - }; -} diff --git a/packages/lina-web/client/navigation.ts b/packages/lina-web/client/navigation.ts deleted file mode 100644 index 3d89be6..0000000 --- a/packages/lina-web/client/navigation.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { ChatModel } from "./model.ts"; -import { element, setText } from "./render.ts"; -import type { createSettings } from "./settings.ts"; -import { installSidebarResize } from "./sidebar-resize.ts"; - -export function createNavigation( - settings: ReturnType, - openSearch: (opener: HTMLElement) => void, -) { - installSidebarResize(settings); - const sidebar = element("sidebar", HTMLElement), - workspace = sidebar.parentElement; - if (!workspace) throw Error("Missing workspace"); - const drawer = element("sidebar-drawer", HTMLDialogElement), - open = element("open-sidebar", HTMLButtonElement), - collapse = element("collapse-sidebar", HTMLButtonElement); - const account = element("account-button", HTMLButtonElement), - menu = element("account-menu", HTMLDivElement), - search = element("open-search", HTMLButtonElement), - shortcuts = element("shortcuts-dialog", HTMLDialogElement); - const mobile = matchMedia("(max-width: 767px)"), - mac = /Mac|iPhone|iPad/.test(navigator.platform), - modifier = mac ? "⌘" : "Ctrl"; - setText(element("search-shortcut", HTMLElement), `${modifier} K`); - setText(element("settings-shortcut", HTMLElement), `${modifier} ,`); - setText(element("modifier-send-label", HTMLElement), `${modifier} + Enter`); - for (const label of shortcuts.querySelectorAll( - "[data-shortcut]", - )) - setText(label, `${modifier} ${label.dataset["shortcut"]?.toUpperCase()}`); - const items = Array.from(menu.querySelectorAll("button")); - const closeMenu = (focus = false) => { - menu.hidden = true; - account.setAttribute("aria-expanded", "false"); - if (focus) account.focus(); - }; - const restoreSidebar = () => { - workspace.prepend(sidebar); - open.setAttribute("aria-expanded", "false"); - }; - const closeDrawer = () => { - closeMenu(); - if (drawer.open) drawer.close(); - restoreSidebar(); - }; - const opener = () => (mobile.matches ? open : account); - const prepare = () => { - const target = opener(); - closeMenu(); - closeDrawer(); - return target; - }; - const openSettings = () => { - const target = prepare(); - shortcuts.close(); - settings.open(target); - }; - const showSearch = () => { - const target = mobile.matches ? open : search; - prepare(); - shortcuts.close(); - element("settings-dialog", HTMLDialogElement).close(); - openSearch(target); - }; - account.addEventListener("click", () => { - if (!menu.hidden) { - closeMenu(); - return; - } - menu.hidden = false; - account.setAttribute("aria-expanded", "true"); - items[0]?.focus(); - }); - menu.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - closeMenu(true); - return; - } - if (event.key === "Tab") { - closeMenu(true); - return; - } - const index = items.indexOf(document.activeElement as HTMLButtonElement); - if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { - event.preventDefault(); - const next = - event.key === "Home" - ? 0 - : event.key === "End" - ? items.length - 1 - : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % - items.length; - items[next]?.focus(); - } - }); - document.addEventListener("pointerdown", (event) => { - if ( - event.target instanceof Node && - !menu.contains(event.target) && - !account.contains(event.target) - ) - closeMenu(); - }); - element("open-settings", HTMLButtonElement).addEventListener( - "click", - openSettings, - ); - element("open-shortcuts", HTMLButtonElement).addEventListener("click", () => { - const target = prepare(); - shortcuts.showModal(); - shortcuts.addEventListener("close", () => target.focus(), { once: true }); - }); - for (const button of shortcuts.querySelectorAll("[data-close]")) - button.addEventListener("click", () => shortcuts.close()); - shortcuts.addEventListener("click", (event) => { - if (event.target === shortcuts) { - const r = shortcuts.getBoundingClientRect(); - if ( - event.clientX < r.left || - event.clientX > r.right || - event.clientY < r.top || - event.clientY > r.bottom - ) - shortcuts.close(); - } - }); - search.addEventListener("click", showSearch); - open.addEventListener("click", () => { - drawer.append(sidebar); - drawer.showModal(); - open.setAttribute("aria-expanded", "true"); - }); - element("close-sidebar", HTMLButtonElement).addEventListener( - "click", - closeDrawer, - ); - drawer.addEventListener("close", restoreSidebar); - drawer.addEventListener("click", (event) => { - if (event.target === drawer) closeDrawer(); - }); - mobile.addEventListener("change", () => { - if (!mobile.matches) closeDrawer(); - }); - const updateCollapse = () => { - collapse.setAttribute( - "aria-expanded", - String(!settings.preferences.sidebarCollapsed), - ); - collapse.setAttribute( - "aria-label", - settings.preferences.sidebarCollapsed - ? "사이드바 펼치기" - : "사이드바 접기", - ); - }; - collapse.addEventListener("click", () => { - settings.update({ - sidebarCollapsed: !settings.preferences.sidebarCollapsed, - }); - updateCollapse(); - }); - updateCollapse(); - element("open-lina", HTMLButtonElement).addEventListener("click", () => { - closeDrawer(); - element("message", HTMLTextAreaElement).focus(); - }); - document.addEventListener("keydown", (event) => { - if (event.isComposing || event.altKey || !(event.metaKey || event.ctrlKey)) - return; - if (event.key.toLowerCase() === "k" || event.code === "KeyK") { - event.preventDefault(); - showSearch(); - } - if (event.key === ",") { - event.preventDefault(); - element("search-dialog", HTMLDialogElement).close(); - openSettings(); - } - }); - return (model: ChatModel) => { - const last = model.messages.findLast( - (m) => m.role === "assistant" || m.role === "user", - ); - setText( - element("agent-preview", HTMLElement), - last?.text.trim().startsWith("```") - ? "코드 답변" - : last?.text - .split(/\r?\n/) - .find((line) => line.trim()) - ?.replace(/^#{1,6}\s*/, "") - .replace(/[`*_]/g, "") - .slice(0, 100) || "대화 시작", - ); - }; -} diff --git a/packages/lina-web/client/settings-tab-model.ts b/packages/lina-web/client/settings-tab-model.ts deleted file mode 100644 index 29b00de..0000000 --- a/packages/lina-web/client/settings-tab-model.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const SETTINGS_TABS = ["general", "connections", "models"] as const; -export type SettingsTab = (typeof SETTINGS_TABS)[number]; - -export function nextSettingsTab( - current: SettingsTab, - key: string, -): SettingsTab | null { - if (key === "Home") return "general"; - if (key === "End") return "models"; - const delta = - key === "ArrowRight" || key === "ArrowDown" - ? 1 - : key === "ArrowLeft" || key === "ArrowUp" - ? -1 - : 0; - return delta - ? (SETTINGS_TABS[ - (SETTINGS_TABS.indexOf(current) + delta + SETTINGS_TABS.length) % - SETTINGS_TABS.length - ] ?? current) - : null; -} diff --git a/packages/lina-web/client/settings-tabs.ts b/packages/lina-web/client/settings-tabs.ts deleted file mode 100644 index 06569fd..0000000 --- a/packages/lina-web/client/settings-tabs.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - nextSettingsTab, - SETTINGS_TABS, - type SettingsTab, -} from "./settings-tab-model.ts"; - -export function installSettingsTabs( - root: HTMLElement, - changed: (tab: SettingsTab) => void, -) { - let current: SettingsTab = "general"; - const navigation = root.querySelector('[role="tablist"]'); - const mobile = window.matchMedia("(max-width: 767px)"); - const orientation = () => - navigation?.setAttribute( - "aria-orientation", - mobile.matches ? "horizontal" : "vertical", - ); - orientation(); - mobile.addEventListener("change", orientation); - const buttons = SETTINGS_TABS.map((id) => { - const button = root.querySelector(`#settings-tab-${id}`); - const panel = root.querySelector(`#settings-panel-${id}`); - if (!button || !panel) throw Error("Missing settings panel"); - return { id, button, panel }; - }); - const select = (id: SettingsTab, focus = false) => { - if (id !== current) - root - .querySelector(".settings-content") - ?.scrollTo({ top: 0 }); - current = id; - for (const item of buttons) { - const selected = item.id === id; - item.button.setAttribute("aria-selected", String(selected)); - item.button.tabIndex = selected ? 0 : -1; - item.panel.hidden = !selected; - if (selected && focus) item.button.focus(); - } - changed(id); - }; - for (const { id, button } of buttons) { - button.addEventListener("click", () => select(id)); - button.addEventListener("keydown", (event) => { - const next = nextSettingsTab(current, event.key); - if (!next) return; - event.preventDefault(); - select(next, true); - }); - } - return { - select, - get current() { - return current; - }, - }; -} diff --git a/packages/lina-web/client/tokens.css b/packages/lina-web/client/tokens.css deleted file mode 100644 index 93655c5..0000000 --- a/packages/lina-web/client/tokens.css +++ /dev/null @@ -1,52 +0,0 @@ -:root { - color-scheme: dark; - --app-bg: #17181b; - --surface-main: #222326; - --surface-sidebar: #1b1c1f; - --surface-inspector: #1e1f22; - --surface-raised: #2b2c30; - --input-bg: #292a2e; - --input-border: #72747e; - --text: #eeeef1; - --muted: #b0b1b9; - --line: #393a40; - --line-strong: #55575f; - --accent: #83afff; - --accent-hover: #aac8ff; - --selection: #29364d; - --button-bg: #e5e5e9; - --button-text: #242428; - --button-hover: #fafafb; - --disabled-bg: #35363b; - --success: #a0c6ad; - --warning: #e8b680; - --danger: #f0a5a1; - --focus-ring: #83afff24; - --tint: var(--surface-raised); - --rail-text: var(--muted); -} -:root[data-theme="light"] { - color-scheme: light; - --app-bg: #eaeaec; - --surface-main: #ffffff; - --surface-sidebar: #f4f4f6; - --surface-inspector: #fafafa; - --surface-raised: #f0f0f3; - --input-bg: #fbfbfc; - --input-border: #858791; - --text: #242428; - --muted: #66666e; - --line: #e0e0e5; - --line-strong: #b2b3bb; - --accent: #345fa8; - --accent-hover: #244b90; - --selection: #e7edf7; - --button-bg: #2b2b30; - --button-text: #ffffff; - --button-hover: #101014; - --disabled-bg: #e7e7eb; - --success: #326649; - --warning: #875922; - --danger: #a03935; - --focus-ring: #345fa824; -} diff --git a/packages/lina-web/client/tsconfig.json b/packages/lina-web/client/tsconfig.json deleted file mode 100644 index 1fc574f..0000000 --- a/packages/lina-web/client/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": [] }, - "include": ["./*.ts", "../src/protocol.ts"], - "exclude": [] -} diff --git a/packages/lina-web/scripts/qa-pwa.ts b/packages/lina-web/scripts/qa-pwa.ts index 10231eb..700d46e 100644 --- a/packages/lina-web/scripts/qa-pwa.ts +++ b/packages/lina-web/scripts/qa-pwa.ts @@ -51,7 +51,7 @@ process.on("SIGUSR1", () => { void (async () => { await web.stop(true); const original = await loadWebAssets(); - const client = new URL("../client/", import.meta.url); + const client = new URL("../../lina-ui/client/", import.meta.url); const fresh = { ...original, ...createPwaAssets( diff --git a/packages/lina-web/src/assets.ts b/packages/lina-web/src/assets.ts index d55a2d9..bca788e 100644 --- a/packages/lina-web/src/assets.ts +++ b/packages/lina-web/src/assets.ts @@ -11,6 +11,8 @@ async function buildClientEntry(entry: URL): Promise { fileURLToPath(entry), "--target=browser", "--minify", + "--define", + 'process.env.NODE_ENV="production"', ], { cwd: fileURLToPath(new URL(".", entry)), @@ -30,14 +32,23 @@ async function buildClientEntry(entry: URL): Promise { } export async function loadWebAssets(): Promise { - const client = new URL("../client/", import.meta.url); - const [script, css, themeScript] = await Promise.all( + const client = new URL("../../lina-ui/client/", import.meta.url); + const [builtScript, css, themeScript] = await Promise.all( ["app.ts", "styles.css", "theme-boot.ts"].map((entry) => buildClientEntry(new URL(entry, client)), ), ); - if (script === undefined || css === undefined || themeScript === undefined) + if ( + builtScript === undefined || + css === undefined || + themeScript === undefined + ) throw new Error("Missing web assets"); + const licenses = await Bun.file( + new URL("../THIRD_PARTY_LICENSES.txt", client), + ).text(); + // The same output is served on web and bundled into Electron's ASAR. + const script = `/*!\n${licenses.replaceAll("*/", "* /")}\n*/\n${builtScript}`; const base = { icon: await Bun.file(new URL("favicon.svg", client)).text(), html: await Bun.file(new URL("index.html", client)).text(), diff --git a/packages/lina-web/src/protocol.ts b/packages/lina-web/src/protocol.ts index 223aba2..61a3727 100644 --- a/packages/lina-web/src/protocol.ts +++ b/packages/lina-web/src/protocol.ts @@ -1,79 +1 @@ -export const MAX_CHAT_LENGTH = 16_000; -export type ChatFrame = { - readonly type: "chat"; - readonly id: string; - readonly text: string; -}; -export type ServerFrame = - | ContextServer - | ControlServer - | WireServer - | { readonly type: "ack"; readonly id: string } - | { readonly type: "error"; readonly message: string } - | { readonly type: "agent-text" | "agent-thinking"; readonly text: string } - | { readonly type: "agent-status"; readonly state: "running" | "idle" }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function record(raw: unknown): Record | undefined { - if (typeof raw !== "string" || raw.length > 1_048_576) return; - try { - const value: unknown = JSON.parse(raw); - if (isRecord(value)) return value; - } catch { - /* Invalid network frames carry no trusted data. */ - } - return; -} - -export function parseChat(raw: unknown): ChatFrame | undefined { - const value = parseWireClient(raw); - return value?.type === "chat" ? value : undefined; -} - -export function parseServerFrame(raw: unknown): ServerFrame | undefined { - const context = parseContextServer(raw); - if (context) return context; - const control = parseControlServer(raw); - if (control) return control; - const durable = parseWireServer(raw); - if (durable) return durable; - const value = record(raw); - if (value === undefined) return; - const { type, id, message, text, state } = value; - switch (type) { - case "ack": - return typeof id === "string" && id.length > 0 - ? { type: "ack", id } - : undefined; - case "error": - return typeof message === "string" - ? { type: "error", message } - : undefined; - case "agent-text": - case "agent-thinking": - return typeof text === "string" ? { type, text } : undefined; - case "agent-status": - return state === "idle" || state === "running" - ? { type: "agent-status", state } - : undefined; - default: - return; - } -} - -import { - type ContextServer, - parseContextServer, -} from "../../lina-core/src/context-wire.ts"; -import { - type ControlServer, - parseControlServer, -} from "../../lina-core/src/control-wire.ts"; -import { - parseWireClient, - parseWireServer, - type WireServer, -} from "../../lina-core/src/wire.ts"; +export * from "../../lina-client/src/protocol.ts"; diff --git a/packages/lina-web/src/server.ts b/packages/lina-web/src/server.ts index b3ea9dc..cb1ee33 100644 --- a/packages/lina-web/src/server.ts +++ b/packages/lina-web/src/server.ts @@ -1,11 +1,11 @@ import type { ServerWebSocket } from "bun"; +import { parseServerFrame } from "../../lina-client/src/protocol.ts"; import { parseContextClient } from "../../lina-core/src/context-wire.ts"; import { parseControlClient } from "../../lina-core/src/control-wire.ts"; import { parseWireClient } from "../../lina-core/src/wire.ts"; import { createAccessPolicy } from "./access.ts"; import { proxyAgents } from "./agent-proxy.ts"; import { proxyAttachment } from "./attachment-proxy.ts"; -import { parseServerFrame } from "./protocol.ts"; import type { PwaAssets } from "./pwa-assets.ts"; diff --git a/packages/lina-web/test/agent-draft.test.ts b/packages/lina-web/test/agent-draft.test.ts index 36750cf..d4219a2 100644 --- a/packages/lina-web/test/agent-draft.test.ts +++ b/packages/lina-web/test/agent-draft.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { DraftStore } from "../client/draft.ts"; +import { DraftStore } from "../../lina-client/src/draft.ts"; test("agent drafts and unresolved sends stay separate while legacy Lina keys remain", () => { const data = new Map(); diff --git a/packages/lina-web/test/agent-editor.test.ts b/packages/lina-web/test/agent-editor.test.ts index 74c9781..9b15e2f 100644 --- a/packages/lina-web/test/agent-editor.test.ts +++ b/packages/lina-web/test/agent-editor.test.ts @@ -9,7 +9,7 @@ import { createAgentPicker, type EditorDependencies, type EditorNode, -} from "../client/agent-editor.ts"; +} from "../../lina-ui/client/agent-editor.ts"; type Listener = (event: FakeEvent) => void; class FakeEvent { diff --git a/packages/lina-web/test/agent-list.test.ts b/packages/lina-web/test/agent-list.test.ts new file mode 100644 index 0000000..d388e4d --- /dev/null +++ b/packages/lina-web/test/agent-list.test.ts @@ -0,0 +1,312 @@ +import { expect, test } from "bun:test"; +import { + type AgentConversationSummary, + AgentListModel, + type AgentListProfile, + publicMessagePreview, + summaryFromSnapshot, +} from "../../lina-client/src/agent-list.ts"; +import type { SessionSnapshot } from "../../lina-core/src/protocol.ts"; + +const profiles: AgentListProfile[] = [ + { id: "lina", name: "Lina", role: "개인 에이전트", avatarId: null }, + { id: "writer", name: "긴 이름 작가", role: "글쓰기", avatarId: null }, + { id: "research", name: "Research", role: "자료 조사", avatarId: null }, +]; +const snapshot = (seq = 2): SessionSnapshot => ({ + version: 2, + botId: "writer", + sessionId: "conversation-writer", + revision: seq, + state: "idle", + requests: [], + hasEarlier: false, + beforeCursor: 1, + messages: [ + { + entryId: `reply-${seq}`, + seq, + role: "assistant", + text: "내일 이어서 이야기해요.", + timestamp: "2026-09-07T13:18:00Z", + truncated: false, + }, + ], +}); +const summary = (seq = 2): AgentConversationSummary => + summaryFromSnapshot(snapshot(seq), 0); + +test("selection, summaries and drafts preserve every agent in server order", () => { + const model = new AgentListModel("writer"); + model.replace(profiles); + model.updateSummary(summary()); + model.setDraft("research", "새 초안"); + model.setCurrent("research"); + expect(model.rows().map((row) => row.id)).toEqual([ + "lina", + "writer", + "research", + ]); + expect( + model + .rows() + .filter((row) => row.selected) + .map((row) => row.id), + ).toEqual(["research"]); + model.setFilter(" 글쓰기 "); + expect(model.rows().map((row) => row.id)).toEqual(["writer"]); + model.setFilter("RESEARCH"); + expect(model.rows().map((row) => row.id)).toEqual(["research"]); +}); + +test("confirmation outranks draft, draft outranks message and neither changes message time", () => { + const model = new AgentListModel("writer"); + model.replace(profiles); + model.updateSummary({ ...summary(), confirmationCount: 2 }); + model.setDraft("writer", "아직 보내지 않은 말"); + expect(model.rows()[1]?.preview).toBe("확인 필요 · 2건"); + model.setCurrent("writer"); + expect(model.rows()[1]?.confirmationCount).toBe(2); + model.updateSummary(summary(3)); + expect(model.rows()[1]?.preview).toBe("초안: 아직 보내지 않은 말"); + expect(model.rows()[1]?.timestamp).toBe("2026-09-07T13:18:00Z"); + model.setDraft("writer", ""); + expect(model.rows()[1]?.preview).toBe("내일 이어서 이야기해요."); +}); + +test("unread requires a visible cursor, survives selection and is separate from confirmation", () => { + const model = new AgentListModel("writer"); + model.replace(profiles); + model.updateSummary(summary()); + expect(model.rows()[1]?.unread).toBe(false); + model.markVisible("writer", "wrong-session", 2); + model.updateSummary(summary(3)); + expect(model.rows()[1]?.unread).toBe(false); + model.markVisible("writer", "conversation-writer", 3); + model.updateSummary({ ...summary(4), confirmationCount: 1 }); + model.setCurrent("writer"); + expect(model.rows()[1]?.unread).toBe(true); + model.markVisible("writer", "conversation-writer", 999); + expect(model.rows()[1]?.unread).toBe(true); + model.markVisible("writer", "conversation-writer", 4); + expect(model.rows()[1]?.unread).toBe(false); + expect(model.rows()[1]?.confirmationCount).toBe(1); +}); + +test("stale revisions do not overwrite public messages; unavailable is not an empty conversation", () => { + const model = new AgentListModel("writer"); + model.replace(profiles); + expect(model.rows()[1]?.preview).toBe("대화 요약을 불러오는 중…"); + model.updateSummary(summary(5)); + model.updateSummary(summary(2)); + expect(model.rows()[1]?.messageSequence).toBe(5); + model.summariesFailed(); + expect(model.rows()[1]?.summaryUnavailable).toBe(true); + expect(model.rows()[1]?.preview).toBe("내일 이어서 이야기해요."); + expect(model.rows()[0]?.preview).toBe("대화 요약을 불러오지 못했어요"); +}); + +test("snapshot projection excludes tool/meta text and does not label missing approvals as zero", () => { + const value = snapshot(); + value.messages.push({ + ...{ + entryId: "tool", + text: "", + timestamp: "2026-09-07T00:00:00Z", + truncated: false, + }, + seq: 99, + role: "tool", + text: "INTERNAL SECRET", + }); + const result = summaryFromSnapshot(value); + expect(result.latestMessage?.seq).toBe(2); + expect(result.confirmationCount).toBeNull(); + expect(publicMessagePreview({ role: "user", text: "내일\n봐요" })).toBe( + "나: 내일 봐요", + ); + expect( + publicMessagePreview({ role: "assistant", text: "```ts\nconst x=1\n```" }), + ).toBe("코드 답변"); + expect( + publicMessagePreview({ + role: "user", + text: "[보고서.pdf](/api/attachments/11111111-1111-1111-1111-111111111111?sessionId=22222222-2222-2222-2222-222222222222)", + }), + ).toBe("나: 보고서.pdf"); +}); + +test("GET agents reads persisted public summaries after restart without opening an engine", async () => { + const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } = + await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { DurableStore } = await import("../../lina-core/src/store.ts"); + const { AgentFleet } = await import( + "../../lina-runtime/src/fleet/manager.ts" + ); + const { startFleetServer } = await import( + "../../lina-runtime/src/fleet/server.ts" + ); + const root = mkdtempSync(join(tmpdir(), "lina-list-test-")); + let opened = 0; + const fleet = new AgentFleet({ + workspace: root, + resourceRoot: process.cwd(), + stateRoot: root, + agentDir: join(root, "auth"), + systemPrompt: "test", + createApp: async () => { + opened++; + throw Error("must not open"); + }, + }); + const primary = fleet.presets.find((profile) => profile.id === "lina"); + if (!primary) throw Error("Missing primary"); + fleet.agents.create({ ...primary, id: "writer", name: "Writer" }); + fleet.agents.create({ ...primary, id: "unopened", name: "Unopened" }); + const writerRoot = join(root, "agents", "writer"); + mkdirSync(writerRoot, { recursive: true }); + const binding = { + version: 1 as const, + botId: "writer", + sessionId: "writer-session", + sessionFile: join(writerRoot, "session.jsonl"), + workspace: root, + }; + writeFileSync(join(writerRoot, "binding.json"), JSON.stringify(binding)); + const path = join(writerRoot, "state.sqlite"); + const store = new DurableStore(path, binding); + store.appendEntry({ + entryId: "public", + role: "assistant", + text: "확정된 답변", + timestamp: "2026-09-07T13:18:00Z", + raw: { message: { phase: "final_answer" } }, + }); + store.appendEntry({ + entryId: "commentary", + role: "assistant", + text: "PRIVATE COMMENTARY", + timestamp: "2026-09-07T13:19:00Z", + raw: { message: { phase: "commentary" } }, + }); + store.appendEntry({ + entryId: "tool", + role: "tool", + text: "PRIVATE TOOL", + timestamp: "2026-09-07T13:20:00Z", + raw: {}, + }); + store.close(); + // Finalize the fixture WAL before comparing file bytes; the reader cannot checkpoint it. + const { DatabaseSync } = await import("node:sqlite"); + const checkpoint = new DatabaseSync(path); + checkpoint.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + checkpoint.close(); + const original = readFileSync(path); + const server = await startFleetServer(fleet, 0, process.cwd(), "lina", { + lazy: true, + }); + try { + const response = await fetch(`http://127.0.0.1:${server.port}/api/agents`); + const data = (await response.json()) as { + agents: { id: string }[]; + summaries: AgentConversationSummary[]; + }; + expect(response.status).toBe(200); + expect(data.summaries?.map((item) => item.agentId)).toEqual( + data.agents.map((item) => item.id), + ); + const writer = data.summaries.find((item) => item.agentId === "writer"); + expect(writer?.latestMessage).toMatchObject({ + entryId: "public", + text: "확정된 답변", + timestamp: "2026-09-07T13:18:00Z", + seq: 1, + }); + expect(writer?.confirmationCount).toBeNull(); + expect(writer?.running).toBeNull(); + expect( + data.summaries.find((item) => item.agentId === "unopened")?.available, + ).toBe(true); + expect(JSON.stringify(data)).not.toContain("PRIVATE"); + expect(opened).toBe(0); + expect(readFileSync(path)).toEqual(original); + writeFileSync( + join(writerRoot, "binding.json"), + JSON.stringify({ ...binding, botId: "foreign" }), + ); + const failed = (await fetch( + `http://127.0.0.1:${server.port}/api/agents`, + ).then((result) => result.json())) as typeof data; + expect( + failed.summaries.find((item) => item.agentId === "writer")?.available, + ).toBe(false); + expect(JSON.stringify(failed.summaries)).not.toContain("확정된 답변"); + expect( + ( + await fetch(`http://127.0.0.1:${server.port}/api/agents`, { + headers: { origin: "https://foreign.invalid" }, + }) + ).status, + ).toBe(403); + } finally { + await server.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("visible cursors persist per agent and session while storage failure stays usable", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + }; + const first = new AgentListModel("writer", storage); + first.replace(profiles); + first.updateSummary(summary(3)); + first.markVisible("writer", "conversation-writer", 3); + const restored = new AgentListModel("writer", storage); + restored.replace(profiles); + restored.updateSummary(summary(4)); + expect(restored.rows()[1]?.unread).toBe(true); + restored.updateSummary({ ...summary(5), sessionId: "new-conversation" }); + expect(restored.rows()[1]?.unread).toBe(false); + const blocked = new AgentListModel("writer", { + getItem: () => { + throw Error("blocked"); + }, + setItem: () => { + throw Error("blocked"); + }, + }); + blocked.replace(profiles); + blocked.updateSummary(summary(4)); + blocked.markVisible("writer", "conversation-writer", 4); + blocked.updateSummary(summary(5)); + expect(blocked.rows()[1]?.unread).toBe(true); +}); + +test("a user's own message does not become an unread assistant reply", () => { + const model = new AgentListModel("writer"); + model.replace(profiles); + model.updateSummary(summary(2)); + model.markVisible("writer", "conversation-writer", 2); + const value = snapshot(3); + value.messages = [ + { + entryId: "own", + seq: 3, + role: "user", + text: "내가 보낸 말", + timestamp: "2026-09-07T13:18:00Z", + truncated: false, + }, + ]; + model.updateSummary(summaryFromSnapshot(value, 0)); + expect(model.rows()[1]?.unread).toBe(false); + expect(model.rows()[1]?.preview).toBe("나: 내가 보낸 말"); +}); diff --git a/packages/lina-web/test/attachment-draft.test.ts b/packages/lina-web/test/attachment-draft.test.ts index 218c65a..2b03eba 100644 --- a/packages/lina-web/test/attachment-draft.test.ts +++ b/packages/lina-web/test/attachment-draft.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import { AttachmentDraft, attachmentLink } from "../client/attachment-draft.ts"; +import { + AttachmentDraft, + attachmentLink, +} from "../../lina-client/src/attachment-draft.ts"; const session = "12345678-1234-4234-8234-123456789012"; const ref = { diff --git a/packages/lina-web/test/attachment-queue.test.ts b/packages/lina-web/test/attachment-queue.test.ts new file mode 100644 index 0000000..f6e2159 --- /dev/null +++ b/packages/lina-web/test/attachment-queue.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from "bun:test"; +import { + AttachmentDraft, + attachmentLink, + type DraftAttachment, +} from "../../lina-client/src/attachment-draft.ts"; +import { AttachmentQueue } from "../../lina-client/src/attachment-queue.ts"; + +const firstSession = "12345678-1234-4234-8234-123456789012"; +const secondSession = "12345678-1234-4234-8234-123456789013"; +const file = (name: string) => + new File(["sample"], name, { type: "text/plain" }); +const receipt = ( + index: number, + name: string, + sessionId = firstSession, +): DraftAttachment => ({ + id: `87654321-1234-4234-8234-${String(index).padStart(12, "0")}`, + name, + sessionId, +}); +function harness() { + const draft = new AttachmentDraft(); + let session: string | undefined = firstSession; + let signal = Promise.withResolvers(); + const requests: { + file: File; + owner: string; + signal: AbortSignal; + resolve: (ref: DraftAttachment) => void; + reject: (error: Error) => void; + }[] = []; + const notices: string[] = []; + const queue = new AttachmentQueue(draft, { + session: () => session, + text: () => draft.text, + changed: () => { + signal.resolve(); + signal = Promise.withResolvers(); + }, + notice: (message) => notices.push(message), + upload: (file, owner, signal) => + new Promise((resolve, reject) => + requests.push({ file, owner, signal, resolve, reject }), + ), + }); + queue.update(true); + const waitFor = async (check: () => boolean) => { + while (!check()) await signal.promise; + }; + const request = (index: number) => { + const r = requests[index]; + if (!r) throw Error(`missing request ${index}`); + return r; + }; + return { + draft, + queue, + requests, + notices, + waitFor, + request, + session: (value: string | undefined) => { + session = value; + queue.update(true); + }, + }; +} + +test("one queue accepts picker and paste while busy and preserves all item states", async () => { + const h = harness(); + h.queue.addFiles([file("one.txt"), file("two.txt")], "picker"); + h.queue.addFiles([file("three.txt")], "paste"); + expect(h.queue.items.map((item) => item.state)).toEqual([ + "uploading", + "pending", + "pending", + ]); + expect(h.queue.busy).toBe(true); + expect(h.queue.blocked).toBe(true); + h.request(0).reject(Error("storage unavailable")); + await h.waitFor(() => h.requests.length === 2); + expect(h.queue.items.map((item) => item.state)).toEqual([ + "error", + "uploading", + "pending", + ]); + h.request(1).resolve(receipt(2, "two.txt")); + await h.waitFor(() => h.requests.length === 3); + h.request(2).resolve(receipt(3, "three.txt")); + await h.waitFor(() => !h.queue.busy); + expect(h.queue.blocked).toBe(true); + expect(h.draft.refs.map((ref) => ref.name)).toEqual(["two.txt", "three.txt"]); + const failed = h.queue.items[0]; + if (!failed) throw Error("missing failed item"); + h.queue.retry(failed.key); + h.request(3).resolve(receipt(1, "one.txt")); + await h.waitFor(() => !h.queue.busy); + expect(h.queue.items.map((item) => item.state)).toEqual([ + "ready", + "ready", + "ready", + ]); + expect(h.queue.blocked).toBe(false); + expect(h.draft.refs.map((ref) => ref.name)).toEqual([ + "one.txt", + "two.txt", + "three.txt", + ]); +}); + +test("max four counts ready, errors and queued files and rejects an oversized batch explicitly", async () => { + const h = harness(); + h.queue.addFiles( + [file("1.txt"), file("2.txt"), file("3.txt"), file("4.txt")], + "paste", + ); + h.request(0).reject(Error("offline")); + await h.waitFor(() => h.requests.length === 2); + h.queue.addFiles([file("5.txt")], "picker"); + expect(h.queue.items).toHaveLength(4); + expect(h.notices.at(-1)).toContain("4개"); + const failed = h.queue.items[0]; + if (!failed) throw Error("missing failed item"); + h.queue.remove(failed.key); + h.queue.addFiles([file("5.txt"), file("6.txt")], "paste"); + expect(h.queue.items).toHaveLength(3); + h.queue.addFiles([file("5.txt")], "picker"); + expect(h.queue.items).toHaveLength(4); + h.queue.reset(""); +}); + +test("reset cancels pending work and ignores stale success and failure within the same session", async () => { + for (const staleFailure of [false, true]) { + const h = harness(); + h.queue.addFiles([file("old.txt"), file("never.txt")], "picker"); + h.queue.reset("new draft"); + expect(h.request(0).signal.aborted).toBe(true); + h.queue.addFiles([file("new.txt")], "paste"); + if (staleFailure) h.request(0).reject(Error("stale failure")); + else h.request(0).resolve(receipt(1, "old.txt")); + h.request(1).resolve(receipt(2, "new.txt")); + await h.waitFor(() => !h.queue.busy); + expect(h.draft.value).toBe( + `new draft\n\n${attachmentLink(receipt(2, "new.txt"))}`, + ); + expect(h.requests.map((r) => r.file.name)).toEqual(["old.txt", "new.txt"]); + expect(h.notices).toEqual([]); + } +}); + +test("session changes abort old uploads, block foreign refs and forbid retry into the new session", async () => { + const h = harness(); + h.queue.reset(attachmentLink(receipt(1, "saved.txt"))); + h.queue.addFiles([file("old.txt"), file("pending.txt")], "paste"); + h.session(secondSession); + expect(h.request(0).signal.aborted).toBe(true); + expect(h.queue.busy).toBe(false); + expect(h.queue.blocked).toBe(true); + const old = h.queue.items[1]; + if (!old) throw Error("missing old item"); + h.queue.retry(old.key); + expect(h.requests).toHaveLength(1); + h.queue.reset(""); + h.queue.addFiles([file("new.txt")], "picker"); + h.request(0).resolve(receipt(2, "old.txt")); + h.request(1).resolve(receipt(3, "new.txt", secondSession)); + await h.waitFor(() => !h.queue.busy); + expect(h.draft.refs).toEqual([receipt(3, "new.txt", secondSession)]); + expect(h.request(1).owner).toBe(secondSession); +}); + +test("removing an upload aborts it, advances the queue and ignores its late receipt", async () => { + const h = harness(); + h.queue.addFiles([file("removed.txt"), file("kept.txt")], "paste"); + const removed = h.queue.items[0]; + if (!removed) throw Error("missing item"); + h.queue.remove(removed.key); + expect(h.request(0).signal.aborted).toBe(true); + h.request(0).resolve(receipt(1, "removed.txt")); + h.request(1).resolve(receipt(2, "kept.txt")); + await h.waitFor(() => !h.queue.busy); + expect(h.draft.refs).toEqual([receipt(2, "kept.txt")]); +}); + +test("disconnect retains files, cancels in-flight upload and requires explicit retry", async () => { + const h = harness(); + h.queue.addFiles([file("first.txt"), file("second.txt")], "paste"); + h.queue.update(false); + expect(h.request(0).signal.aborted).toBe(true); + expect(h.queue.items.map((item) => item.state)).toEqual(["error", "pending"]); + h.queue.update(true); + expect(h.requests.map((r) => r.file.name)).toEqual([ + "first.txt", + "second.txt", + ]); + h.request(0).resolve(receipt(1, "first.txt")); + h.request(1).resolve(receipt(2, "second.txt")); + await h.waitFor(() => !h.queue.busy); + expect(h.queue.blocked).toBe(true); + expect(h.draft.refs).toEqual([receipt(2, "second.txt")]); +}); + +test("restored uploaded references retain exact draft serialization and removal", () => { + const h = harness(); + const original = `read this\n${attachmentLink(receipt(1, "saved.txt"))}`; + h.queue.reset(original); + expect(h.draft.value).toBe(original); + expect(h.queue.items[0]?.state).toBe("ready"); + expect(h.queue.blocked).toBe(false); + const saved = h.queue.items[0]; + if (!saved) throw Error("missing saved item"); + h.queue.remove(saved.key); + expect(h.draft.value).toBe("read this"); +}); + +test("unnamed PNG and JPEG uploads get unique extensions without altering bytes", async () => { + const h = harness(); + h.queue.addFiles( + [ + new File(["png bytes"], "", { type: "image/png" }), + new File(["jpeg bytes"], "", { type: "image/jpeg" }), + new File(["png bytes"], "", { type: "image/png" }), + ], + "paste", + ); + for (const [index, extension, bytes] of [ + [0, ".png", "png bytes"], + [1, ".jpg", "jpeg bytes"], + [2, ".png", "png bytes"], + ] as const) { + const request = h.request(index); + expect(request.file.name.endsWith(extension)).toBe(true); + expect(await request.file.text()).toBe(bytes); + request.resolve(receipt(index + 1, request.file.name)); + await h.waitFor(() => + index === 2 ? !h.queue.busy : h.requests.length === index + 2, + ); + } + expect(new Set(h.requests.map((request) => request.file.name)).size).toBe(3); +}); diff --git a/packages/lina-web/test/clipboard-attachments.test.ts b/packages/lina-web/test/clipboard-attachments.test.ts new file mode 100644 index 0000000..788d9ec --- /dev/null +++ b/packages/lina-web/test/clipboard-attachments.test.ts @@ -0,0 +1,398 @@ +import { afterEach, expect, test } from "bun:test"; + +// Keep DOM globals out of the Bun root program; the browser build checks the +// renderer against lib.dom. This harness describes only its public boundary. +type Attachments = { + port: { value: string }; + busy: boolean; + blocked: boolean; + update(online: boolean): void; + dispose(): void; +}; +const moduleUrl = new URL( + "../../lina-ui/client/attachments.ts", + import.meta.url, +); +const { createAttachments } = (await import(moduleUrl.href)) as { + createAttachments( + input: Node, + session: () => string | undefined, + changed: () => void, + notice: (message: string) => void, + ): Attachments; +}; + +class Node extends EventTarget { + children: Node[] = []; + attributes = new Map(); + value = ""; + textContent = ""; + className = ""; + hidden = false; + disabled = false; + files: File[] = []; + selectionStart = 0; + selectionEnd = 0; + append(...nodes: Node[]) { + this.children.push(...nodes); + } + replaceChildren(...nodes: Node[]) { + this.children = nodes; + } + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + } + getAttribute(name: string) { + return this.attributes.get(name) ?? null; + } + click() { + this.dispatchEvent(new Event("click")); + } + focus() {} + setRangeText(text: string, start: number, end: number) { + this.value = this.value.slice(0, start) + text + this.value.slice(end); + this.selectionStart = this.selectionEnd = start + text.length; + } +} + +const session = "12345678-1234-4234-8234-123456789012"; +const ref = { id: "87654321-1234-4234-8234-123456789012", name: "one.txt" }; +const file = (name = "one.txt", type = "text/plain") => + new File(["sample"], name, { type, lastModified: 1 }); +let restore = () => {}; +afterEach(() => restore()); + +function harness() { + const nodes = new Map( + ["message", "draft-files", "file-picker", "attach-file"].map((id) => [ + id, + new Node(), + ]), + ); + const get = (id: string) => { + const node = nodes.get(id); + if (!node) throw Error(id); + return node; + }; + const uploads: { + init: RequestInit; + resolve: (response: Response) => void; + }[] = []; + let signal = Promise.withResolvers(); + let changes = 0; + let changedHook = () => {}; + const changed = () => { + changes++; + signal.resolve(); + signal = Promise.withResolvers(); + changedHook(); + }; + const notices: string[] = []; + const globals: Record = { + document: { getElementById: get, createElement: () => new Node() }, + HTMLDivElement: Node, + HTMLInputElement: Node, + HTMLButtonElement: Node, + fetch: (_url: string, init: RequestInit) => + new Promise((resolve) => uploads.push({ init, resolve })), + }; + const descriptors = new Map( + Object.keys(globals).map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key), + ]), + ); + for (const [key, value] of Object.entries(globals)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + restore = () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }; + const input = get("message"); + // Minimal DOM stand-in; browser build separately checks the actual DOM contract. + const attachments = createAttachments( + input, + () => session, + changed, + (message) => notices.push(message), + ); + const restoreGlobals = restore; + restore = () => { + attachments.dispose(); + restoreGlobals(); + }; + attachments.update(true); + const waitFor = async (check: () => boolean) => { + while (!check()) await signal.promise; + }; + const pick = (...files: File[]) => { + const picker = get("file-picker"); + picker.files = files; + picker.dispatchEvent(new Event("change")); + }; + const paste = (files: File[], text = "", fallback = files) => { + const event = Object.assign(new Event("paste", { cancelable: true }), { + clipboardData: { + items: files.map((value) => ({ kind: "file", getAsFile: () => value })), + files: fallback, + getData: (type: string) => + type === "text/plain" ? text : "", + }, + }); + input.dispatchEvent(event); + return event; + }; + return { + input, + attachments, + uploads, + notices, + get, + pick, + paste, + waitFor, + changes: () => changes, + onChange: (hook: () => void) => { + changedHook = hook; + }, + }; +} + +test("parent update callbacks do not recurse or replace unchanged attachment rows", async () => { + const h = harness(); + h.onChange(() => h.attachments.update(true)); + expect(h.changes()).toBe(0); + h.pick(file()); + const uploading = h.get("draft-files").children[0]; + for (let i = 0; i < 100; i++) h.attachments.update(true); + expect(h.changes()).toBe(1); + expect(h.get("draft-files").children[0]).toBe(uploading); + expect(h.uploads).toHaveLength(1); + h.uploads[0]?.resolve(new Response(null, { status: 507 })); + await h.waitFor(() => !h.attachments.busy); + expect(h.changes()).toBe(2); + const failed = h.get("draft-files").children[0]; + for (let i = 0; i < 100; i++) h.attachments.update(true); + expect(h.get("draft-files").children[0]).toBe(failed); + expect(h.changes()).toBe(2); + expect(h.uploads).toHaveLength(1); +}); + +test("picker additions during upload remain visible and queued", () => { + const h = harness(); + h.pick(file()); + h.pick(file("two.txt")); + expect(h.get("draft-files").children).toHaveLength(2); + expect(h.uploads).toHaveLength(1); + expect(h.get("attach-file").disabled).toBe(false); +}); + +test("mixed file/text paste replaces the selection once, emits input, and deduplicates items/files", () => { + const h = harness(); + h.input.value = "before selected after"; + h.input.selectionStart = 7; + h.input.selectionEnd = 15; + let inputs = 0; + h.input.addEventListener("input", () => { + inputs++; + }); + const event = h.paste([file()], "한글\ntext"); + expect(event.defaultPrevented).toBe(true); + expect(h.input.value).toBe("before 한글\ntext after"); + expect(inputs).toBe(1); + expect(h.get("draft-files").children).toHaveLength(1); + expect(h.uploads).toHaveLength(1); +}); + +test("plain text, URL and local path paste leave native editing untouched", () => { + const h = harness(); + h.input.value = "keep"; + let inputs = 0; + h.input.addEventListener("input", () => { + inputs++; + }); + for (const text of [ + "한글\ntext", + "https://example.com/a.png", + "/home/example/a.png", + ]) { + expect(h.paste([], text).defaultPrevented).toBe(false); + } + expect(h.input.value).toBe("keep"); + expect(inputs).toBe(0); + expect(h.uploads).toHaveLength(0); +}); + +test("files fallback and unnamed images use the same picker/paste queue without deduping later pastes", () => { + const h = harness(); + const png = file("", "image/png"); + h.paste([], "", [png]); + h.paste([png]); + h.paste([file("", "image/jpeg")]); + expect(h.get("draft-files").children).toHaveLength(3); + expect( + new Headers(h.uploads[0]?.init.headers).get("X-Lina-Filename"), + ).toMatch(/\.png$/); +}); + +test("resetting the draft aborts upload and a late receipt cannot contaminate the new draft", async () => { + const h = harness(); + h.pick(file()); + const upload = h.uploads[0]; + if (!upload) throw Error("missing upload"); + h.attachments.port.value = "new draft"; + expect(upload.init.signal?.aborted).toBe(true); + h.pick(file("new.txt")); + upload.resolve(Response.json(ref)); + h.uploads[1]?.resolve( + Response.json({ + ...ref, + id: "87654321-1234-4234-8234-123456789013", + name: "new.txt", + }), + ); + await h.waitFor(() => !h.attachments.busy); + expect(h.attachments.port.value).toBe( + `new draft\n\n[new.txt](/api/attachments/87654321-1234-4234-8234-123456789013?sessionId=${session})`, + ); + expect(h.get("draft-files").children).toHaveLength(1); +}); + +test("clipboard file items are authoritative even when files exposes extra wrappers", () => { + const h = harness(); + const first = file(); + h.paste([first], "", [file(), file("other.txt")]); + expect(h.get("draft-files").children).toHaveLength(1); + // Different copies with the same metadata remain distinct within one source. + h.paste([first, file()], "", [first, file()]); + expect(h.get("draft-files").children).toHaveLength(3); +}); + +test("one paste with different lastModified wrappers uploads once; a later paste stays separate", async () => { + const h = harness(); + const item = new File(["png bytes"], "image.png", { + type: "image/png", + lastModified: 1, + }); + const wrapper = new File(["png bytes"], "image.png", { + type: "image/png", + lastModified: 2, + }); + h.paste([item], "", [wrapper]); + expect(h.get("draft-files").children).toHaveLength(1); + h.uploads[0]?.resolve(Response.json({ ...ref, name: "image.png" })); + await h.waitFor(() => !h.attachments.busy); + expect(h.uploads).toHaveLength(1); + expect( + h.get("draft-files").children[0]?.getAttribute("data-attachment-state"), + ).toBe("ready"); + h.paste([item], "", [wrapper]); + expect(h.get("draft-files").children).toHaveLength(2); + expect(h.uploads).toHaveLength(2); +}); + +test.each([ + [413, "2 MiB"], + [507, "저장 공간"], + [500, "저장소 오류"], + [400, "형식"], +] as const)( + "HTTP %i leaves a recoverable file with the appropriate reason", + async (status, expected) => { + const h = harness(); + h.pick(file()); + h.uploads[0]?.resolve(new Response(null, { status })); + await h.waitFor(() => !h.attachments.busy); + expect(h.attachments.blocked).toBe(true); + const row = h.get("draft-files").children[0]; + expect(row?.getAttribute("data-attachment-state")).toBe("error"); + expect( + row?.children.find((node) => node.getAttribute("role") === "alert") + ?.textContent, + ).toContain(expected); + const retry = row?.children.find((node) => + node.getAttribute("aria-label")?.endsWith("재시도"), + ); + expect(retry?.disabled).toBe(false); + retry?.click(); + expect(h.uploads).toHaveLength(2); + h.uploads[1]?.resolve(Response.json(ref)); + await h.waitFor(() => !h.attachments.busy); + expect(h.attachments.blocked).toBe(false); + expect(h.attachments.port.value).toContain( + `[one.txt](/api/attachments/${ref.id}?sessionId=${session})`, + ); + const ready = h.get("draft-files").children[0]; + expect(ready?.children.some((node) => node.textContent === "준비됨")).toBe( + true, + ); + ready?.children + .find((node) => node.getAttribute("aria-label")?.endsWith("제외")) + ?.click(); + expect(h.attachments.port.value).toBe(""); + }, +); + +test("2 MiB is accepted; a larger file stays visible as an error and can be excluded", async () => { + const h = harness(); + h.pick( + new File([new Uint8Array(2097152)], "fits.png", { type: "image/png" }), + ); + expect(h.uploads).toHaveLength(1); + h.pick( + new File([new Uint8Array(2097153)], "large.png", { type: "image/png" }), + ); + h.uploads[0]?.resolve(Response.json({ ...ref, name: "fits.png" })); + await h.waitFor(() => !h.attachments.busy); + expect(h.uploads).toHaveLength(1); + expect( + h + .get("draft-files") + .children.map((node) => node.getAttribute("data-attachment-state")), + ).toEqual(["ready", "error"]); + expect(h.attachments.blocked).toBe(true); + h.get("draft-files") + .children[1]?.children.find((node) => + node.getAttribute("aria-label")?.endsWith("제외"), + ) + ?.click(); + expect(h.attachments.blocked).toBe(false); +}); + +test("a malformed JSON receipt displays a recovery action without exposing parser errors", async () => { + const h = harness(); + h.pick(file()); + h.uploads[0]?.resolve(new Response("server error")); + await h.waitFor(() => !h.attachments.busy); + const error = h + .get("draft-files") + .children[0]?.children.find( + (node) => node.getAttribute("role") === "alert", + ); + expect(error?.textContent).toBe( + "첨부 결과 미확인 · 재시도 또는 제외해 주세요.", + ); + expect(h.attachments.blocked).toBe(true); +}); + +test.each([ + {}, + { id: "invalid", name: "bad.txt" }, + { ...ref, name: "../../bad.txt" }, +])("invalid receipts never become uploaded draft links: %j", async (meta) => { + const h = harness(); + h.pick(file()); + h.uploads[0]?.resolve(Response.json(meta)); + await h.waitFor(() => !h.attachments.busy); + expect(h.attachments.port.value).toBe(""); + expect(h.attachments.blocked).toBe(true); + expect( + h.get("draft-files").children[0]?.getAttribute("data-attachment-state"), + ).toBe("error"); +}); diff --git a/packages/lina-web/test/connection.test.ts b/packages/lina-web/test/connection.test.ts index 79d4dd1..364935b 100644 --- a/packages/lina-web/test/connection.test.ts +++ b/packages/lina-web/test/connection.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { Connection } from "../client/connection.ts"; -import { ChatModel } from "../client/model.ts"; +import { Connection } from "../../lina-client/src/connection.ts"; +import { ChatModel } from "../../lina-client/src/model.ts"; const cleanup: (() => void)[] = []; afterEach(() => { diff --git a/packages/lina-web/test/context-model.test.ts b/packages/lina-web/test/context-model.test.ts index 42a76a7..f080b33 100644 --- a/packages/lina-web/test/context-model.test.ts +++ b/packages/lina-web/test/context-model.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; +import { ContextModel } from "../../lina-client/src/context-model.ts"; +import { parseServerFrame } from "../../lina-client/src/protocol.ts"; import type { ContextSnapshot } from "../../lina-core/src/context-wire.ts"; -import { ContextModel } from "../client/context-model.ts"; -import { parseServerFrame } from "../src/protocol.ts"; const snapshot = (epoch = "one", revision = 2): ContextSnapshot => ({ sessionId: "s", diff --git a/packages/lina-web/test/conversation-editor.test.ts b/packages/lina-web/test/conversation-editor.test.ts index f30e016..6b5b123 100644 --- a/packages/lina-web/test/conversation-editor.test.ts +++ b/packages/lina-web/test/conversation-editor.test.ts @@ -5,7 +5,7 @@ import { conversationPreferenceLabel, conversationPreferenceValue, validateConversationExamples, -} from "../client/conversation-draft.ts"; +} from "../../lina-ui/client/conversation-draft.ts"; test("builds the scoped conversation endpoint", () => { expect(conversationEndpoint("lina")).toBe("/api/agents/lina/conversation"); diff --git a/packages/lina-web/test/draft.test.ts b/packages/lina-web/test/draft.test.ts index a3893b3..628f6a0 100644 --- a/packages/lina-web/test/draft.test.ts +++ b/packages/lina-web/test/draft.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { DraftStore } from "../client/draft.ts"; +import { DraftStore } from "../../lina-client/src/draft.ts"; test("draft and pending ID survive reload without executing a send", () => { const values = new Map(); diff --git a/packages/lina-web/test/durable-model.test.ts b/packages/lina-web/test/durable-model.test.ts index fdf12c7..4a91bc4 100644 --- a/packages/lina-web/test/durable-model.test.ts +++ b/packages/lina-web/test/durable-model.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; +import { DurableChatModel } from "../../lina-client/src/durable-model.ts"; import type { SessionSnapshot, TimelineEntry, } from "../../lina-core/src/protocol.ts"; -import { DurableChatModel } from "../client/durable-model.ts"; const row = ( seq: number, diff --git a/packages/lina-web/test/execution-model.test.ts b/packages/lina-web/test/execution-model.test.ts index 76e895c..be98510 100644 --- a/packages/lina-web/test/execution-model.test.ts +++ b/packages/lina-web/test/execution-model.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; +import { ExecutionModel } from "../../lina-client/src/execution-model.ts"; import type { ControlSnapshot } from "../../lina-core/src/control/types.ts"; -import { ExecutionModel } from "../client/execution-model.ts"; const state = (revision = 1): ControlSnapshot => ({ sessionId: "s", diff --git a/packages/lina-web/test/fixtures/codex-only-ui.ts b/packages/lina-web/test/fixtures/codex-only-ui.ts index d1fbee4..99f1a54 100644 --- a/packages/lina-web/test/fixtures/codex-only-ui.ts +++ b/packages/lina-web/test/fixtures/codex-only-ui.ts @@ -4,7 +4,7 @@ import type { ControlSnapshot } from "../../../lina-core/src/control/types.ts"; import { parseControlClient } from "../../../lina-core/src/control-wire.ts"; import type { SessionSnapshot } from "../../../lina-core/src/protocol.ts"; import { parseWireClient } from "../../../lina-core/src/wire.ts"; -import type { TaskDetail } from "../../client/task-view.ts"; +import type { TaskDetail } from "../../../lina-ui/client/task-view.ts"; import { loadWebAssets } from "../../src/assets.ts"; import { startWebServer } from "../../src/server.ts"; diff --git a/packages/lina-web/test/fixtures/model-combobox.ts b/packages/lina-web/test/fixtures/model-combobox.ts new file mode 100644 index 0000000..fe671a3 --- /dev/null +++ b/packages/lina-web/test/fixtures/model-combobox.ts @@ -0,0 +1,55 @@ +import type { + ModelChoice, + ModelComboboxAdapter, + ModelComboboxFactory, +} from "../../../lina-ui/client/model-combobox-types.ts"; + +/** Controller seam only: browser QA owns search, focus, keyboard, and popup behavior. */ +export function comboboxFixture() { + const controls = new Map< + string, + ModelComboboxAdapter & { items: ModelChoice[]; choose(value: string): void } + >(); + const factory: ModelComboboxFactory = (doc, id, label, change) => { + const root = doc.createElement("div"); + const input = doc.createElement("input"); + input.id = `${id}-input`; + input.setAttribute("aria-label", label); + root.append(input); + const control = { + root, + input, + items: [] as ModelChoice[], + close() {}, + set( + items: ModelChoice[], + _value: string, + display: string, + disabled = false, + ) { + control.items = items; + input.value = display; + input.disabled = disabled; + }, + disable(disabled: boolean) { + input.disabled = disabled; + }, + choose(value: string) { + if ( + input.disabled || + !control.items.some((item) => item.value === value) + ) + throw Error(`Unavailable model choice: ${id}/${value}`); + change(value); + }, + }; + controls.set(id, control); + return control; + }; + const get = (id: string) => { + const control = controls.get(id); + if (!control) throw Error(`Missing combobox: ${id}`); + return control; + }; + return { factory, get }; +} diff --git a/packages/lina-web/test/fixtures/ui-desktop.ts b/packages/lina-web/test/fixtures/ui-desktop.ts new file mode 100644 index 0000000..f0f1b26 --- /dev/null +++ b/packages/lina-web/test/fixtures/ui-desktop.ts @@ -0,0 +1,469 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { summaryFromSnapshot } from "../../../lina-client/src/agent-list.ts"; +import { AttachmentStore } from "../../../lina-core/src/attachments/store.ts"; +import { handleAttachmentRequest } from "../../../lina-runtime/src/attachment-http.ts"; + +/** Browser-only fixture: real web assets/gateway, in-memory conversation/task APIs. */ + +import type { ControlSnapshot } from "../../../lina-core/src/control/types.ts"; +import { parseControlClient } from "../../../lina-core/src/control-wire.ts"; +import type { SessionSnapshot } from "../../../lina-core/src/protocol.ts"; +import { parseWireClient } from "../../../lina-core/src/wire.ts"; +import type { TaskDetail } from "../../../lina-ui/client/task-view.ts"; +import { loadWebAssets } from "../../src/assets.ts"; +import { startWebServer } from "../../src/server.ts"; + +const timestamp = new Date().toISOString(); +const sessionId = randomUUID(); +const snapshot: SessionSnapshot = { + version: 2, + botId: "lina", + sessionId, + revision: 1, + state: "running", + hasEarlier: false, + beforeCursor: null, + requests: [], + messages: [ + { + seq: 1, + entryId: "user-1", + role: "user", + timestamp, + truncated: false, + text: "로그인 흐름을 확인하고, 변경 전에 알려줘.", + }, + { + seq: 2, + entryId: "assistant-1", + role: "assistant", + timestamp, + truncated: false, + text: "로그인 오류를 확인했어요. 수정 내용을 검증하기 전에 승인을 기다리고 있어요.", + }, + ], +}; +const inputJson = JSON.stringify({ command: "bun test" }); +const control: ControlSnapshot = { + sessionId, + revision: 1, + cancelRequestId: "request-1", + cancelling: false, + cancelFailed: false, + tools: [ + { + id: "tool-1", + nativeCallId: "native-1", + requestId: "request-1", + name: "bash", + state: "waiting_approval", + inputPreview: inputJson, + outputPreview: "", + createdAt: timestamp, + updatedAt: timestamp, + }, + ], + approvals: [ + { + id: "approval-1", + toolRunId: "tool-1", + inputDigest: "a".repeat(64), + inputJson, + state: "pending", + expiresAt: Date.now() + 3_600_000, + createdAt: timestamp, + }, + ], +}; +const task: TaskDetail = { + task: { + id: "task-1", + threadId: "thread-1", + ownerAgentId: "lina", + title: "로그인 오류와 긴 안내 문구 확인", + cwd: "/synthetic/workspace", + status: "waiting_approval", + revision: 1, + updatedAt: timestamp, + model: "synthetic-model", + pendingApprovals: [ + { + id: "task-approval", + method: "item/commandExecution/requestApproval", + params: { command: "bun test", cwd: "/synthetic/workspace" }, + createdAt: timestamp, + }, + ], + }, + thread: { + id: "thread-1", + turns: [ + { + id: "turn-1", + status: "inProgress", + error: null, + items: [ + { + type: "userMessage", + content: [{ type: "text", text: "로그인 오류를 확인해줘." }], + }, + { + type: "agentMessage", + text: "입력 검증을 확인했습니다. 테스트 실행은 승인을 기다립니다.", + }, + ], + }, + ], + }, +}; +const commands: unknown[] = []; +const root = mkdtempSync(join(tmpdir(), "lina-ui-desktop-qa-")); +const presentation = process.env["LINA_QA_PRESENTATION"] === "1"; +const sampleNames = [ + "Lina", + "Kai", + "하루", + "소담", + "유리", + "노아", + "미오", + "서우", + "다온", + "루카", +]; +const count = Number(process.env["LINA_QA_AGENT_COUNT"] ?? 10); +if (!Number.isInteger(count) || count < 1 || count > 50) + throw new Error("Invalid fixture agent count"); +const profiles = Array.from({ length: count }, (_, index) => ({ + id: index === 0 ? "lina" : index === 1 ? "kai" : `agent-${index}`, + name: presentation + ? (sampleNames[index] ?? `동료 ${index}`) + : index === 0 + ? "Lina" + : index === 1 + ? "Kai" + : `긴 이름을 가진 에이전트 ${index}`, + role: index === 0 ? "함께 이야기하는 동료" : "글쓰기와 자료 조사", + personality: "차분함", + voice: "편안하게", + profile: "", + appearance: "", + interests: [], + avatarId: null, + evolution: "adaptive", + revision: 1, + state: "idle", + dynamics: { + revision: 0, + mood: null, + interests: [], + preferences: [], + relationship: [], + lastRequestId: null, + }, +})); +const snapshots = new Map( + profiles.map((profile, index) => [ + profile.id, + { + ...structuredClone(snapshot), + botId: profile.id, + state: index === 0 ? "running" : "idle", + sessionId: index === 0 ? sessionId : randomUUID(), + messages: Array.from({ length: 32 }, (_, n) => ({ + seq: n + 1, + entryId: `${profile.id}-entry-${n}`, + role: n % 2 ? ("assistant" as const) : ("user" as const), + timestamp, + truncated: false, + text: + `${profile.name}의 대화 ${n + 1}. ` + + (n % 2 + ? "오늘 생각한 내용을 함께 정리해 볼까요? 긴 한글 문장도 자연스럽게 읽을 수 있어요." + : "이 대화의 맥락과 초안을 다음에도 유지해줘."), + })), + } satisfies SessionSnapshot, + ]), +); +// The presentation dataset exercises normal rendering with readable synthetic content. +// Default stress/behavior fixtures above remain unchanged. +if (presentation) { + const conversation = [ + "이번 주말에는 좀 쉬고 싶어. 반나절 정도만 밖에 다녀올까?", + "좋아요. 멀리 가기보다, 걷다가 마음에 드는 곳에 잠깐 머무는 주말은 어때요?", + "성수 쪽이 좋겠어. 너무 빡빡한 일정은 말고.", + "그럼 두 군데만 정해둘게요. 나머지 시간은 비워두고요.\n\n### 느긋하게 보내는 토요일\n\n- **오전 11시 · 서울숲 산책**\n 천천히 한 바퀴 걸어요. 쉬고 싶으면 벤치에 앉아 있어도 좋고요.\n- **오후 1시 · 점심과 커피**\n 산책을 마치고 골목을 둘러보며 마음에 드는 곳을 골라요.\n\n돌아오는 시간은 정하지 않을게요. 더 걷고 싶으면 걷고, 피곤하면 바로 집으로 가요.", + "좋다. 챙길 것도 간단히 적어줘.", + "편한 신발, 물 한 병, 이어폰이면 충분해요. 작은 가방 하나로 가볍게 다녀와요.", + ]; + const previews = [ + "", + "초안의 첫 문장을 조금 다듬어 봤어요.", + "내일은 가볍게 20분만 걸어볼까요?", + "읽고 싶은 책 세 권을 골라봤어요.", + "여행 준비 목록을 정리했어요.", + "오늘 들을 음악을 찾아뒀어요.", + "사진 속 빛이 참 따뜻하네요.", + "다음 주 일정, 여유 있게 잡아둘게요.", + "생각나면 언제든 이어서 이야기해요.", + "저녁은 간단하게 만들어볼까요?", + ]; + for (const [index, profile] of profiles.entries()) { + const value = snapshots.get(profile.id); + if (!value) continue; + const text = + index === 0 + ? conversation + : [ + "오늘 이야기한 내용 정리해줘.", + previews[index] ?? "함께 천천히 정리해봐요.", + ]; + value.messages = text.map((content, n) => ({ + seq: n + 1, + entryId: `${profile.id}-sample-${n}`, + role: n % 2 ? "assistant" : "user", + timestamp: new Date(Date.now() - index * 23 * 60_000).toISOString(), + truncated: false, + text: content, + })); + } +} +const stores = new Map( + profiles.map((profile) => { + const folder = join(root, profile.id); + mkdirSync(folder); + const binding = { + version: 1 as const, + botId: profile.id, + sessionId: snapshots.get(profile.id)?.sessionId ?? sessionId, + sessionFile: join(folder, "session.jsonl"), + workspace: folder, + }; + return [binding.sessionId, new AttachmentStore(folder, binding)] as const; + }), +); +const persist = () => + writeFileSync( + join(root, "conversations.json"), + JSON.stringify([...snapshots]), + ); +persist(); +const upstream = Bun.serve<{ agentId: string }>({ + hostname: "127.0.0.1", + port: Number(process.env["LINA_QA_UPSTREAM_PORT"] ?? 18142), + async fetch(request, server) { + const path = new URL(request.url).pathname; + if ( + request.headers.get("upgrade") === "websocket" && + server.upgrade(request, { + data: { + agentId: new URL(request.url).searchParams.get("agent") ?? "lina", + }, + }) + ) + return; + if (path === "/fixture/commands") return Response.json(commands); + if (path === "/api/onboarding/entry") + return Response.json({ + firstUser: process.env["LINA_QA_FIRST_USER"] === "1", + resume: null, + legacyDrafts: [], + }); + if (path === "/api/models") + return Response.json({ + settings: { + revision: 1, + profiles: [], + defaultProfileId: null, + roles: {}, + agentRoles: {}, + }, + catalog: [], + active: [], + }); + if (path === "/api/agents/lina/intro" && request.method === "POST") + return Response.json({ + room: { + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + agentId: "lina", + kind: "user", + status: "active", + revision: 0, + mode: "thoughtful", + draftId: null, + data: { + profile: profiles[0], + chapters: {}, + user: {}, + summary: [], + ready: false, + }, + createdAt: Date.now(), + finalization: null, + }, + turns: [], + userRevision: 0, + shareUser: false, + presets: [], + sessionId: null, + }); + if (path.startsWith("/api/attachments")) { + const id = + request.headers.get("X-Lina-Session") ?? + new URL(request.url).searchParams.get("sessionId") ?? + ""; + const store = stores.get(id); + if (!store) return new Response("Unknown session", { status: 404 }); + return ( + (await handleAttachmentRequest(request, store, store.binding)) ?? + new Response("Not found", { status: 404 }) + ); + } + if (path === "/api/agents") + return Response.json({ + agents: profiles, + presets: [], + summaries: [...snapshots.values()].map((item) => + summaryFromSnapshot( + item, + item.botId === "lina" ? control.approvals.length : 0, + ), + ), + }); + if (/^\/api\/agents\/[^/]+\/intro$/.test(path)) + return Response.json({ room: null }); + if (/^\/api\/agents\/[^/]+$/.test(path)) { + const profile = profiles.find( + (item) => item.id === path.split("/").at(-1), + ); + return Response.json({ + profile, + dynamics: profile?.dynamics, + changes: [], + }); + } + if (path === "/api/tasks" && request.method === "GET") + return Response.json({ + tasks: + !new URL(request.url).searchParams.get("ownerAgentId") || + new URL(request.url).searchParams.get("ownerAgentId") === + task.task.ownerAgentId + ? [task.task] + : [], + }); + if (path === "/api/tasks/task-1" && request.method === "GET") + return Response.json(task); + if (path.startsWith("/api/tasks") && request.method === "POST") { + const body = (await request.json()) as Record; + commands.push({ path, body }); + if (body["expectedRevision"] !== task.task.revision) + return Response.json( + { error: "작업이 변경되었습니다." }, + { status: 409 }, + ); + if (path.endsWith("/messages")) + return Response.json( + { error: "연결을 확인하고 다시 보내주세요." }, + { status: 503 }, + ); + if (path.endsWith("/approval")) { + task.task.pendingApprovals = []; + task.task.status = "inProgress"; + } + if (path.endsWith("/interrupt")) task.task.status = "interrupted"; + if (path.endsWith("/owner")) + task.task.ownerAgentId = String(body["ownerAgentId"]); + task.task.revision++; + return Response.json(task); + } + return Response.json( + { error: "Synthetic fixture has no such route." }, + { status: 404 }, + ); + }, + websocket: { + open(socket) { + socket.send(JSON.stringify({ type: "agent-status", state: "running" })); + }, + message(socket, raw) { + const snapshot = snapshots.get(socket.data.agentId); + if (!snapshot) return; + const text = String(raw); + const frame = parseWireClient(text) ?? parseControlClient(text); + if (!frame) return; + commands.push(frame); + if (frame.type === "chat") { + socket.send(JSON.stringify({ type: "ack", id: frame.id })); + for (const [role, text] of [ + ["user", frame.text], + [ + "assistant", + "입력과 첨부를 받았어요. 이 응답은 UI 검증용 합성 데이터입니다.", + ], + ] as const) + snapshot.messages.push({ + seq: snapshot.messages.length + 1, + entryId: randomUUID(), + role, + text, + timestamp: new Date().toISOString(), + truncated: false, + }); + snapshot.state = "idle"; + snapshot.revision++; + persist(); + } + if (frame.type === "ping") { + socket.send(JSON.stringify({ ...frame, type: "pong" })); + return; + } + if (frame.type === "approval_reply" || frame.type === "cancel") { + control.approvals = []; + control.tools = []; + control.cancelRequestId = null; + control.revision++; + snapshot.state = "idle"; + snapshot.revision++; + } + socket.send(JSON.stringify({ type: "snapshot", snapshot })); + socket.send( + JSON.stringify({ + type: "control-state", + state: { + ...control, + sessionId: snapshot.sessionId, + ...(snapshot.botId === "lina" + ? {} + : { approvals: [], tools: [], cancelRequestId: null }), + }, + }), + ); + // The real gateway must discard retired frames without affecting conversation. + socket.send( + JSON.stringify({ type: "job-error", message: "RETIRED_JOB_ERROR" }), + ); + }, + }, +}); +const web = startWebServer({ + port: Number(process.env["LINA_QA_WEB_PORT"] ?? 18140), + upstream: `ws://127.0.0.1:${upstream.port}`, + assets: await loadWebAssets(), +}); +console.log( + JSON.stringify({ + url: `http://127.0.0.1:${web.port}/?agent=lina`, + upstream: upstream.port, + root, + }), +); +const stop = () => { + void Promise.all([web.stop(true), upstream.stop(true)]).then(() => + process.exit(0), + ); +}; +process.once("SIGINT", stop); +process.once("SIGTERM", stop); diff --git a/packages/lina-web/test/hub-ui.test.ts b/packages/lina-web/test/hub-ui.test.ts index 2ae6ed0..d223a06 100644 --- a/packages/lina-web/test/hub-ui.test.ts +++ b/packages/lina-web/test/hub-ui.test.ts @@ -6,9 +6,9 @@ import { installHubSettings, parseHubStatus, safeHubGuiUrl, -} from "../client/hub-settings.ts"; -import { installModelSettings } from "../client/model-settings.ts"; -import { nextSettingsTab } from "../client/settings-tab-model.ts"; +} from "../../lina-ui/client/hub-settings.ts"; +import { installModelSettings } from "../../lina-ui/client/model-settings.ts"; +import { comboboxFixture } from "./fixtures/model-combobox.ts"; class Node { id = ""; @@ -209,15 +209,6 @@ function modelFixture() { }; } -test("settings tabs wrap with arrows and support Home/End without consuming unrelated keys", () => { - expect(nextSettingsTab("general", "ArrowRight")).toBe("connections"); - expect(nextSettingsTab("models", "ArrowDown")).toBe("general"); - expect(nextSettingsTab("general", "ArrowLeft")).toBe("models"); - expect(nextSettingsTab("models", "Home")).toBe("general"); - expect(nextSettingsTab("general", "End")).toBe("models"); - expect(nextSettingsTab("connections", "Enter")).toBeNull(); -}); - test("only https Tailscale GUI URLs can be opened in a new tab", () => { expect(safeHubGuiUrl("https://hub.example.ts.net:10100/")).toBe( "https://hub.example.ts.net:10100/", @@ -344,8 +335,10 @@ test("role dropdowns hide unsupported catalog models and refresh keeps the draft defaultProfileId: null, }; let loads = 0; + const combos = comboboxFixture(); const view = installModelSettings( f.dialog, + combos.factory, f.document, async (_path, method, body) => { if (method === "GET") { @@ -368,25 +361,22 @@ test("role dropdowns hide unsupported catalog models and refresh keeps the draft }, ); await view.open(); - const conversation = f.find("model-conversation-input"); - await conversation.fire("focus"); - expect(f.find("model-conversation-list").textContent).toContain("GLM Flash"); - expect(f.find("model-conversation-list").textContent).not.toContain( - "Summary Only", + expect( + combos.get("model-conversation").items.map((item) => item.label), + ).toContain("GLM Flash · opencodex"); + expect( + combos.get("model-conversation").items.map((item) => item.label), + ).not.toContain("Summary Only · opencodex"); + expect( + combos.get("model-conversation").items.map((item) => item.label), + ).not.toContain("Vision · opencodex"); + expect(combos.get("model-summary").items.map((item) => item.label)).toContain( + "Summary Only · opencodex", + ); + expect(combos.get("model-summary").items.map((item) => item.label)).toContain( + "GLM Flash · opencodex", ); - expect(f.find("model-conversation-list").textContent).not.toContain("Vision"); - await conversation.fire("keydown", "Escape"); - const summary = f.find("model-summary-input"); - await summary.fire("focus"); - expect(f.find("model-summary-list").textContent).toContain("Summary Only"); - expect(f.find("model-summary-list").textContent).toContain("GLM Flash"); - await summary.fire("keydown", "Escape"); - const def = f.find("model-default-input"); - await def.fire("focus"); - def.value = "glm"; - await def.fire("input"); - await def.fire("keydown", "ArrowDown"); - await def.fire("keydown", "Enter"); + combos.get("model-default").choose("opencodex/glm-flash"); expect(f.find("model-save").disabled).toBe(false); await f.find("model-reload").fire("click"); expect(f.find("model-save").disabled).toBe(false); diff --git a/packages/lina-web/test/intro-connection.test.ts b/packages/lina-web/test/intro-connection.test.ts index b8118d9..0c9dfe6 100644 --- a/packages/lina-web/test/intro-connection.test.ts +++ b/packages/lina-web/test/intro-connection.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { checkIntroConnection } from "../client/intro-connection.ts"; +import { checkIntroConnection } from "../../lina-ui/client/intro-connection.ts"; const profile = { id: "default", diff --git a/packages/lina-web/test/intro-flow.test.ts b/packages/lina-web/test/intro-flow.test.ts index b6f6ce4..c907eb5 100644 --- a/packages/lina-web/test/intro-flow.test.ts +++ b/packages/lina-web/test/intro-flow.test.ts @@ -6,7 +6,7 @@ import { parseChooseResult, parseEntry, parseTurn, -} from "../client/intro-model.ts"; +} from "../../lina-ui/client/intro-model.ts"; test("first visit and resume route to onboarding, explicit agent route still opens ordinary room", () => { const entry = parseEntry({ firstUser: true, resume: null, legacyDrafts: [] }); @@ -57,7 +57,7 @@ test("a restored failed turn retains original request UUID and user source for r }); test("entry service failure does not prevent ordinary conversation or reopen completed intake", async () => { - const { resolveBoot } = await import("../client/intro-model.ts"); + const { resolveBoot } = await import("../../lina-ui/client/intro-model.ts"); const result = await resolveBoot( parseBootQuery("http://localhost/"), async () => { @@ -82,7 +82,7 @@ test("entry service failure does not prevent ordinary conversation or reopen com }); test("completed first setup ignores a saved user-onboarding URL", async () => { - const { resolveBoot } = await import("../client/intro-model.ts"); + const { resolveBoot } = await import("../../lina-ui/client/intro-model.ts"); let calls = 0; const r = await resolveBoot( parseBootQuery("http://localhost/?onboarding=user"), diff --git a/packages/lina-web/test/intro-starter.test.ts b/packages/lina-web/test/intro-starter.test.ts index 6a48648..731dddb 100644 --- a/packages/lina-web/test/intro-starter.test.ts +++ b/packages/lina-web/test/intro-starter.test.ts @@ -2,8 +2,11 @@ import { expect, test } from "bun:test"; import presets from "../../../data/personas/presets.json"; import { validateAgentInput } from "../../lina-core/src/agents/validation.ts"; import { unspecifiedProfile } from "../../lina-core/src/onboarding/helpers.ts"; -import type { IntroRoom } from "../client/intro-types.ts"; -import { type IntroNode, renderPanel } from "../client/intro-view.ts"; +import type { IntroRoom } from "../../lina-ui/client/intro-types.ts"; +import { + type IntroNode, + renderPanel, +} from "../../lina-ui/client/intro-view.ts"; class Node implements IntroNode { textContent: string | null = ""; diff --git a/packages/lina-web/test/markdown.test.ts b/packages/lina-web/test/markdown.test.ts index f0a5ded..f187460 100644 --- a/packages/lina-web/test/markdown.test.ts +++ b/packages/lina-web/test/markdown.test.ts @@ -3,7 +3,7 @@ import { type MarkdownContainer, parseMarkdown, renderMarkdown, -} from "../client/markdown.ts"; +} from "../../lina-ui/client/markdown.ts"; type Listener = () => void; diff --git a/packages/lina-web/test/mind-view.test.ts b/packages/lina-web/test/mind-view.test.ts index 95d18cd..b5d2bc2 100644 --- a/packages/lina-web/test/mind-view.test.ts +++ b/packages/lina-web/test/mind-view.test.ts @@ -4,7 +4,7 @@ import { createMindView, type MindResponse, recordPresentation, -} from "../client/mind-view.ts"; +} from "../../lina-ui/client/mind-view.ts"; const available: MindResponse = { available: true, @@ -87,7 +87,9 @@ it("discards a retraction response after closing or switching agents", async () }); it("distinguishes changed, unchanged and historical failures without claiming records from receipts", async () => { - const { processingPresentation } = await import("../client/mind-view.ts"); + const { processingPresentation } = await import( + "../../lina-ui/client/mind-view.ts" + ); const text = processingPresentation({ error: null, pending: 2, diff --git a/packages/lina-web/test/model-combobox.test.ts b/packages/lina-web/test/model-combobox.test.ts deleted file mode 100644 index 68029fb..0000000 --- a/packages/lina-web/test/model-combobox.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { expect, test } from "bun:test"; -import { - createModelCombobox, - fitComboboxList, - placedComboboxList, -} from "../client/model-combobox.ts"; - -const qa1280 = { - input: { top: 424, bottom: 466 }, - clip: { top: 80, bottom: 548 }, - obstacle: { top: 500, bottom: 548 }, -}; - -test("short dialog under sticky actions flips the list above the trigger", () => { - const fit = fitComboboxList(qa1280); - const box = placedComboboxList(qa1280.input, fit); - expect(fit.placement).toBe("above"); - expect(box.bottom).toBeLessThanOrEqual(qa1280.input.top); - expect(box.top).toBeGreaterThanOrEqual(qa1280.clip.top); - expect(box.bottom).toBeLessThanOrEqual(qa1280.obstacle.top); - expect(box.bottom).toBeLessThanOrEqual(qa1280.clip.bottom); -}); - -test("overflowing option geometry from the 1280x577 picker stays inside the dialog", () => { - expect(596).toBeGreaterThan(548); - const fit = fitComboboxList(qa1280); - const box = placedComboboxList(qa1280.input, fit); - expect(box.bottom).toBeLessThan(554); - expect(box.bottom).toBeLessThanOrEqual(548); -}); - -test("enough room below keeps the list under the input and above sticky actions", () => { - const input = { top: 120, bottom: 162 }; - const clip = { top: 80, bottom: 548 }; - const obstacle = { top: 500, bottom: 548 }; - const fit = fitComboboxList({ input, clip, obstacle }); - const box = placedComboboxList(input, fit); - expect(fit.placement).toBe("below"); - expect(box.top).toBeGreaterThanOrEqual(input.bottom); - expect(box.bottom).toBeLessThanOrEqual(obstacle.top); - expect(box.bottom).toBeLessThanOrEqual(clip.bottom); -}); - -test("narrow mobile clip still keeps the popup inside the scrollport", () => { - const input = { top: 620, bottom: 662 }; - const clip = { top: 140, bottom: 780 }; - const obstacle = { top: 728, bottom: 780 }; - const fit = fitComboboxList({ input, clip, obstacle }); - const box = placedComboboxList(input, fit); - expect(fit.placement).toBe("above"); - expect(box.top).toBeGreaterThanOrEqual(clip.top); - expect(box.bottom).toBeLessThanOrEqual(obstacle.top); -}); - -class Node { - id = ""; - className = ""; - type = ""; - hidden = false; - disabled = false; - open = true; - min = ""; - max = ""; - step = ""; - checked = false; - parent?: Node; - children: Node[] = []; - attributes = new Map(); - style = { maxHeight: "" }; - rect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; - listeners = new Map< - string, - Array< - (event: { - key?: string; - relatedTarget?: unknown; - preventDefault(): void; - stopPropagation(): void; - }) => unknown - > - >(); - private text = ""; - private selected = ""; - constructor(readonly tag: string) {} - get textContent(): string { - return this.text + this.children.map((node) => node.textContent).join(""); - } - set textContent(value: string) { - this.text = value; - this.children = []; - } - get value(): string { - return this.selected; - } - set value(value: string) { - this.selected = value; - } - setAttribute(name: string, value: string) { - this.attributes.set(name, value); - } - removeAttribute(name: string) { - this.attributes.delete(name); - } - contains(node: unknown): boolean { - return this === node || this.children.some((child) => child.contains(node)); - } - select() {} - scrollIntoView() {} - focus() {} - checkValidity() { - return true; - } - reportValidity() { - return true; - } - append(...nodes: Node[]) { - for (const node of nodes) { - if (node.parent) - node.parent.children = node.parent.children.filter( - (item) => item !== node, - ); - node.parent = this; - this.children.push(node); - } - } - replaceChildren(...nodes: Node[]) { - this.children = []; - this.text = ""; - this.append(...nodes); - } - before() {} - after() {} - querySelectorAll(): Node[] { - return []; - } - querySelector(selector: string): Node | null { - const cls = selector.startsWith(".") ? selector.slice(1) : ""; - const walk = (node: Node): Node | null => { - if (cls && node.className.split(/\s+/).includes(cls)) return node; - for (const child of node.children) { - const found = walk(child); - if (found) return found; - } - return null; - }; - return walk(this); - } - closest(selector: string): Node | null { - const cls = selector.startsWith(".") ? selector.slice(1) : ""; - for (let node: Node | undefined = this; node; node = node.parent) { - if (selector === "dialog" && node.tag === "dialog") return node; - if (cls && node.className.split(/\s+/).includes(cls)) return node; - } - return null; - } - getBoundingClientRect() { - return this.rect; - } - addEventListener( - event: string, - listener: (event: { - key?: string; - relatedTarget?: unknown; - preventDefault(): void; - stopPropagation(): void; - }) => unknown, - ) { - this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); - } - async fire(event: string, key?: string) { - await Promise.all( - (this.listeners.get(event) ?? []).map((listener) => - listener({ - ...(key ? { key } : {}), - preventDefault() {}, - stopPropagation() {}, - }), - ), - ); - } -} - -function widget(onChange: (value: string) => void = () => {}) { - const dialog = new Node("dialog"); - dialog.rect = { - top: 80, - bottom: 548, - left: 200, - right: 1080, - width: 880, - height: 468, - }; - const content = new Node("div"); - content.className = "settings-content"; - content.rect = dialog.rect; - const actions = new Node("div"); - actions.className = "model-actions"; - actions.rect = { - top: 500, - bottom: 548, - left: 200, - right: 1080, - width: 880, - height: 48, - }; - dialog.append(content, actions); - const combo = createModelCombobox( - { - location: { href: "http://fixture/" }, - createElement: (tag: string) => new Node(tag), - getElementById: () => null, - }, - "model-default", - "전역 기본값", - onChange, - ); - const root = combo.root as Node; - content.append(root); - const input = combo.input as Node; - input.rect = { - top: 424, - bottom: 466, - left: 420, - right: 820, - width: 400, - height: 42, - }; - combo.set( - [ - { value: "mini", label: "gpt-5.4-mini · openai" }, - { value: "codex", label: "gpt-5.6-codex · openai" }, - { value: "sol", label: "gpt-5.6-sol · openai" }, - ], - "", - "모델 선택…", - ); - return { combo: { ...combo, root }, input, list: root.children[1] as Node }; -} - -test("opening the default picker in the short dialog applies an above placement", async () => { - const { combo, input, list } = widget(); - await input.fire("focus"); - expect(combo.root.attributes.get("data-placement")).toBe("above"); - expect(combo.root.attributes.get("data-open")).toBe("true"); - const maxHeight = Number.parseInt(list.style.maxHeight, 10); - expect(maxHeight).toBeGreaterThan(0); - expect(maxHeight).toBeLessThanOrEqual(424 - 80); - const box = placedComboboxList(input.rect, { - placement: "above", - maxHeight, - }); - expect(box.bottom).toBeLessThanOrEqual(500); -}); - -test("keyboard search still commits a draft while the list is flipped", async () => { - let chosen = ""; - const { combo, input, list } = widget((value) => { - chosen = value; - }); - await input.fire("focus"); - input.value = "sol"; - await input.fire("input"); - expect(input.value).toBe("sol"); - expect(combo.root.attributes.get("data-placement")).toBe("above"); - expect(list.textContent).toContain("gpt-5.6-sol"); - expect(list.textContent).not.toContain("gpt-5.4-mini"); - await input.fire("keydown", "ArrowDown"); - await input.fire("keydown", "Enter"); - expect(chosen).toBe("sol"); - expect(combo.root.attributes.get("data-open")).toBeUndefined(); -}); diff --git a/packages/lina-web/test/model-settings-ui.test.ts b/packages/lina-web/test/model-settings-ui.test.ts index fa53126..7c56b5a 100644 --- a/packages/lina-web/test/model-settings-ui.test.ts +++ b/packages/lina-web/test/model-settings-ui.test.ts @@ -4,9 +4,10 @@ import type { ModelTrial, } from "../../lina-runtime/src/models/port.ts"; import type { ModelSettings } from "../../lina-runtime/src/models/types.ts"; -import { installModelSettings } from "../client/model-settings.ts"; +import { installModelSettings } from "../../lina-ui/client/model-settings.ts"; +import { comboboxFixture } from "./fixtures/model-combobox.ts"; -// Structural DOM fixture exercises real event handlers without a server or inference. +// Structural DOM fixture exercises controller events; browser QA owns combobox interaction. class Node { id = ""; className = ""; @@ -138,6 +139,7 @@ class Node { } } function fixture() { + const combos = comboboxFixture(); const root = new Node("dialog"); const make = (id: string, tag = "div") => { const node = new Node(tag); @@ -170,6 +172,7 @@ function fixture() { getElementById: find, }; return { + combos, root, find, document, @@ -219,6 +222,7 @@ function setup(initial?: ModelSettings) { }; const view = installModelSettings( f.dialog, + f.combos.factory, f.document, async (_path, method, body) => { if (method === "PATCH") { @@ -243,13 +247,8 @@ function setup(initial?: ModelSettings) { ); return { ...f, view, stored: () => stored }; } -async function choose(f: ReturnType, row: string, query: string) { - const input = f.find(`model-${row}-input`); - await input.fire("focus"); - input.value = query; - await input.fire("input"); - await input.fire("keydown", "ArrowDown"); - await input.fire("keydown", "Enter"); +function choose(f: ReturnType, row: string, value: string) { + f.combos.get(`model-${row}`).choose(value); } test("seven direct combobox rows select and save without registration or token UI", async () => { const f = setup(); @@ -263,12 +262,10 @@ test("seven direct combobox rows select and save without registration or token U "recall", "vision", ]) - expect(f.find(`model-${role}-input`).attributes.get("role")).toBe( - "combobox", - ); + expect(f.combos.get(`model-${role}`).input.id).toBe(`model-${role}-input`); expect(() => f.find("model-output")).toThrow(); expect(() => f.find("model-add-profile")).toThrow(); - await choose(f, "default", "glm"); + await choose(f, "default", "ollama/glm-flash"); await f.find("model-save").fire("click"); expect(f.stored().profiles[0]).toMatchObject({ provider: "ollama", @@ -282,42 +279,34 @@ test("seven direct combobox rows select and save without registration or token U test("role reasoning stays independent while its model keeps inheriting", async () => { const f = setup(); await f.view.open(); - await choose(f, "default", "glm"); + await choose(f, "default", "ollama/glm-flash"); const reason = f.find("model-summary-reasoning"); reason.value = "high"; await reason.fire("change"); - await choose(f, "default", "vision"); + await choose(f, "default", "codex/vision"); await f.find("model-save").fire("click"); expect(f.stored().roles.summary).toBeUndefined(); expect(f.stored().roleReasoning?.summary).toBe("high"); expect(f.find("model-summary-input").value).toContain("Vision"); expect(f.find("model-summary-reasoning").value).toBe("high"); }); -test("search excludes unconfigured providers and vision excludes text-only models", async () => { +test("controller supplies only configured models and image-capable vision choices", async () => { const f = setup(); await f.view.open(); - const input = f.find("model-default-input"); - await input.fire("focus"); - expect(f.find("model-default-list").textContent).not.toContain("Hidden"); - input.value = "no match"; - await input.fire("input"); - expect(f.find("model-default-empty").textContent).toContain( - "검색 결과가 없습니다", + expect(f.combos.get("model-default").items.map((item) => item.value)).toEqual( + ["ollama/glm-flash", "codex/vision"], ); - await input.fire("keydown", "Enter"); + expect(f.combos.get("model-vision").items.map((item) => item.value)).toEqual([ + "", + "codex/vision", + ]); expect(f.find("model-save").disabled).toBe(true); - const vision = f.find("model-vision-input"); - await vision.fire("focus"); - expect(f.find("model-vision-list").textContent).not.toContain("GLM Flash"); - expect(f.find("model-vision-list").textContent).toContain("Vision"); - await vision.fire("keydown", "Escape"); - expect(vision.attributes.get("aria-expanded")).toBe("false"); }); test("agent role selection and reasoning do not change global bindings", async () => { const f = setup(); await f.view.open("alpha"); - await choose(f, "default", "glm"); - await choose(f, "conversation", "vision"); + await choose(f, "default", "ollama/glm-flash"); + await choose(f, "conversation", "codex/vision"); const reason = f.find("model-conversation-reasoning"); reason.value = "medium"; await reason.fire("change"); @@ -335,6 +324,7 @@ test("closing fences a late load failure", async () => { let reject: (reason: Error) => void = () => {}; const view = installModelSettings( f.dialog, + f.combos.factory, f.document, () => new Promise((_, r) => { @@ -349,26 +339,18 @@ test("closing fences a late load failure", async () => { expect(f.find("model-settings-status").textContent).toBe(before); }); -test("model-name search chooses the actual model before inheritance and empty status is outside listbox", async () => { +test("explicit model selection creates a role binding", async () => { const f = setup(); await f.view.open(); - await choose(f, "default", "glm"); - await choose(f, "summary", "glm"); + await choose(f, "default", "ollama/glm-flash"); + await choose(f, "summary", "ollama/glm-flash"); await f.find("model-save").fire("click"); expect(f.stored().roles.summary).toBeDefined(); - const input = f.find("model-summary-input"); - input.value = "missing"; - await input.fire("input"); - expect(f.find("model-summary-empty").parent).not.toBe( - f.find("model-summary-list"), - ); }); test("reselecting inherited model in agent scope creates no false dirty edit", async () => { const f = setup(); await f.view.open("alpha"); - const input = f.find("model-summary-input"); - await input.fire("focus"); - await input.fire("keydown", "Enter"); + f.combos.get("model-summary").choose(""); expect(f.find("model-save").disabled).toBe(true); }); test("disconnected saved selection stays visible and cannot be reselected from catalog", async () => { @@ -384,24 +366,32 @@ test("disconnected saved selection stays visible and cannot be reselected from c await f.view.open(); expect(f.find("model-default-input").value).toContain("연결 확인 필요"); expect(f.find("model-save").disabled).toBe(true); - await f.find("model-default-input").fire("focus"); - expect(f.find("model-default-list").textContent).not.toContain("old-model"); + expect( + f.combos + .get("model-default") + .items.some((item) => item.value === "gone/old-model"), + ).toBe(false); }); test("model role scope includes unopened agents from the fleet catalog", async () => { const f = fixture(); - const view = installModelSettings(f.dialog, f.document, async () => ({ - settings: { - revision: 0, - profiles: [], - roles: {}, - agentRoles: {}, - defaultProfileId: null, - }, - catalog: [], - active: [], - agents: [{ id: "kai", name: "카이" }], - })); + const view = installModelSettings( + f.dialog, + f.combos.factory, + f.document, + async () => ({ + settings: { + revision: 0, + profiles: [], + roles: {}, + agentRoles: {}, + defaultProfileId: null, + }, + catalog: [], + active: [], + agents: [{ id: "kai", name: "카이" }], + }), + ); await view.open(); expect( f.find("model-scope").children.some((option) => option.value === "kai"), diff --git a/packages/lina-web/test/model-settings.test.ts b/packages/lina-web/test/model-settings.test.ts index 0002231..bcf3496 100644 --- a/packages/lina-web/test/model-settings.test.ts +++ b/packages/lina-web/test/model-settings.test.ts @@ -10,7 +10,7 @@ import { createProfile, profileFor, settingsInput, -} from "../client/model-settings.ts"; +} from "../../lina-ui/client/model-settings.ts"; const settings: ModelSettings = { revision: 4, diff --git a/packages/lina-web/test/model.test.ts b/packages/lina-web/test/model.test.ts index 347ced5..f46a8cc 100644 --- a/packages/lina-web/test/model.test.ts +++ b/packages/lina-web/test/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { ChatModel } from "../client/model.ts"; +import { ChatModel } from "../../lina-client/src/model.ts"; describe("web conversation", () => { it("allows only one unresolved request when sending", () => { diff --git a/packages/lina-web/test/navigation-state.test.ts b/packages/lina-web/test/navigation-state.test.ts new file mode 100644 index 0000000..666e900 --- /dev/null +++ b/packages/lina-web/test/navigation-state.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { + navigationLayout, + navigationUrl, + parseNavigation, +} from "../../lina-client/src/navigation-state.ts"; + +test("root opens the agent list, direct agent and task links preserve their destination", () => { + expect(parseNavigation("https://lina.test/")).toEqual({ + screen: "agents", + agentId: "lina", + }); + expect(parseNavigation("https://lina.test/?agent=writer")).toEqual({ + screen: "conversation", + agentId: "writer", + }); + expect( + parseNavigation("https://lina.test/?agent=writer&screen=tasks"), + ).toEqual({ screen: "tasks", agentId: "writer" }); + expect( + parseNavigation("https://lina.test/?agent=../other&screen=bad"), + ).toEqual({ screen: "agents", agentId: "lina" }); +}); + +test("width changes only layout, and extra task column requires explicit choice and room", () => { + const state = { screen: "conversation" as const, agentId: "writer" }; + expect(navigationUrl(state)).toBe("/?agent=writer&screen=conversation"); + expect(navigationLayout(390, true, 304)).toBe("mobile"); + expect(navigationLayout(1280, true, 304)).toBe("single"); + expect(navigationLayout(1440, false, 304)).toBe("single"); + expect(navigationLayout(1440, true, 420)).toBe("single"); + expect(navigationLayout(1440, true, 304)).toBe("dual"); + expect(state).toEqual({ screen: "conversation", agentId: "writer" }); +}); + +test("task deep links preserve the task key independently of the agent conversation", () => { + expect(parseNavigation("https://lina.test/?agent=kai&task=task-1")).toEqual({ + screen: "tasks", + agentId: "kai", + taskId: "task-1", + }); + expect( + navigationUrl({ screen: "tasks", agentId: "kai", taskId: "task-1" }), + ).toBe("/?agent=kai&screen=tasks&task=task-1"); + expect(parseNavigation("https://lina.test/?agent=kai&task=../oops")).toEqual({ + screen: "conversation", + agentId: "kai", + }); +}); diff --git a/packages/lina-web/test/onboarding-controller.test.ts b/packages/lina-web/test/onboarding-controller.test.ts index 204f92d..b0f2d85 100644 --- a/packages/lina-web/test/onboarding-controller.test.ts +++ b/packages/lina-web/test/onboarding-controller.test.ts @@ -1,14 +1,14 @@ import { expect, test } from "bun:test"; import type { AgentInput } from "../../lina-core/src/agents/types.ts"; -import { createOnboardingWizard } from "../client/onboarding.ts"; -import { OnboardingHttpError } from "../client/onboarding-api.ts"; +import { createOnboardingWizard } from "../../lina-ui/client/onboarding.ts"; +import { OnboardingHttpError } from "../../lina-ui/client/onboarding-api.ts"; import { type AgentDraft, emptyChapters, emptyUserAnswers, UNSPECIFIED, type UserState, -} from "../client/onboarding-types.ts"; +} from "../../lina-ui/client/onboarding-types.ts"; type Listener = (event: FakeEvent) => void; class FakeEvent { diff --git a/packages/lina-web/test/onboarding-model.test.ts b/packages/lina-web/test/onboarding-model.test.ts index 38e0ae4..634fb63 100644 --- a/packages/lina-web/test/onboarding-model.test.ts +++ b/packages/lina-web/test/onboarding-model.test.ts @@ -17,12 +17,12 @@ import { shouldClearPreview, userFieldSummaries, userPatch, -} from "../client/onboarding-model.ts"; +} from "../../lina-ui/client/onboarding-model.ts"; import type { AgentDraft, Chapter, UserState, -} from "../client/onboarding-types.ts"; +} from "../../lina-ui/client/onboarding-types.ts"; import { CHAPTER_IDS, emptyChapters, @@ -32,7 +32,7 @@ import { MAX_CHAPTER_TEXT, MAX_USER_ANSWER, UNSPECIFIED, -} from "../client/onboarding-types.ts"; +} from "../../lina-ui/client/onboarding-types.ts"; function profile(id = "agent-test"): AgentInput { return { diff --git a/packages/lina-web/test/preferences.test.ts b/packages/lina-web/test/preferences.test.ts index f434161..5ae867b 100644 --- a/packages/lina-web/test/preferences.test.ts +++ b/packages/lina-web/test/preferences.test.ts @@ -4,8 +4,12 @@ import { readPreferences, savePreferences, shouldSend, -} from "../client/preferences.ts"; -import { readTheme, resolveTheme, saveTheme } from "../client/theme.ts"; +} from "../../lina-client/src/preferences.ts"; +import { + readTheme, + resolveTheme, + saveTheme, +} from "../../lina-ui/client/theme.ts"; test("browser preferences validate saved values and survive blocked storage", () => { let value: string | null = null; @@ -21,14 +25,14 @@ test("browser preferences validate saved values and survive blocked storage", () textSize: "large", sendKey: "modifier", sidebarCollapsed: true, - sidebarWidth: 256, + sidebarWidth: 304, }), ).toBe(true); expect(readPreferences(storage)).toEqual({ textSize: "large", sendKey: "modifier", sidebarCollapsed: true, - sidebarWidth: 256, + sidebarWidth: 304, }); value = '{"textSize":"huge","sendKey":"wrong","sidebarCollapsed":"yes"}'; expect(readPreferences(storage)).toEqual(DEFAULT_PREFERENCES); @@ -92,12 +96,12 @@ test("system preference resolves against OS while explicit theme wins", () => { test("sidebar width validates legacy and damaged device values and preserves a chosen size", () => { const read = (sidebarWidth: unknown) => readPreferences({ getItem: () => JSON.stringify({ sidebarWidth }) }); - expect(read(undefined).sidebarWidth).toBe(256); + expect(read(undefined).sidebarWidth).toBe(304); expect(read(340).sidebarWidth).toBe(340); expect(read(120).sidebarWidth).toBe(200); expect(read(900).sidebarWidth).toBe(420); - expect(read("340").sidebarWidth).toBe(256); - expect(read(null).sidebarWidth).toBe(256); + expect(read("340").sidebarWidth).toBe(304); + expect(read(null).sidebarWidth).toBe(304); let saved = ""; const storage = { getItem: () => saved, diff --git a/packages/lina-web/test/presence.test.ts b/packages/lina-web/test/presence.test.ts index f009687..5cf8db1 100644 --- a/packages/lina-web/test/presence.test.ts +++ b/packages/lina-web/test/presence.test.ts @@ -7,7 +7,7 @@ import { activityStatus, elapsedText, operationLabel, -} from "../client/presence.ts"; +} from "../../lina-ui/client/presence.ts"; const started = "2026-09-06T01:00:00Z"; const tool: ToolRun = { diff --git a/packages/lina-web/test/protocol.test.ts b/packages/lina-web/test/protocol.test.ts index 9504e35..bf693e3 100644 --- a/packages/lina-web/test/protocol.test.ts +++ b/packages/lina-web/test/protocol.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { parseChat, parseServerFrame } from "../src/protocol.ts"; +import { parseChat, parseServerFrame } from "../../lina-client/src/protocol.ts"; describe("web protocol", () => { for (const frame of [ diff --git a/packages/lina-web/test/pwa-assets.test.ts b/packages/lina-web/test/pwa-assets.test.ts index b3dc426..876ddcc 100644 --- a/packages/lina-web/test/pwa-assets.test.ts +++ b/packages/lina-web/test/pwa-assets.test.ts @@ -4,6 +4,8 @@ import { startWebServer } from "../src/server.ts"; test("a complete versioned shell serves manifest, icons and a scoped worker", async () => { const assets = await loadWebAssets(); + expect(assets.script).not.toContain("Download the React DevTools"); + expect(assets.script).toContain("Copyright (c) 2023 shadcn"); expect(assets.pwa).toBeDefined(); if (!assets.pwa) throw new Error("PWA missing"); const { version, files } = assets.pwa; diff --git a/packages/lina-web/test/search-model.test.ts b/packages/lina-web/test/search-model.test.ts index ba4a923..39ef212 100644 --- a/packages/lina-web/test/search-model.test.ts +++ b/packages/lina-web/test/search-model.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { SearchModel } from "../client/search-model.ts"; +import { SearchModel } from "../../lina-client/src/search-model.ts"; const row = { seq: 3, diff --git a/packages/lina-web/test/shared-renderer.test.ts b/packages/lina-web/test/shared-renderer.test.ts new file mode 100644 index 0000000..899b04b --- /dev/null +++ b/packages/lina-web/test/shared-renderer.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; + +test("web assets build the shared renderer and portable client owns wire parsing", async () => { + const ui = new URL("../../lina-ui/client/app.ts", import.meta.url); + expect(existsSync(ui)).toBe(true); + const protocol = await import("../../lina-client/src/protocol.ts"); + expect( + protocol.parseServerFrame('{"type":"agent-status","state":"idle"}'), + ).toEqual({ type: "agent-status", state: "idle" }); + expect( + readFileSync(new URL("../src/assets.ts", import.meta.url), "utf8"), + ).toContain("../../lina-ui/client/"); +}); diff --git a/packages/lina-web/test/task-drafts.test.ts b/packages/lina-web/test/task-drafts.test.ts new file mode 100644 index 0000000..c2755ee --- /dev/null +++ b/packages/lina-web/test/task-drafts.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { TaskDraftStore } from "../../lina-client/src/task-drafts.ts"; + +function memory() { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }; +} +test("task drafts and all-owner scope survive a new client without mixing conversations", () => { + const storage = memory(); + storage.setItem("lina.draft.v1.lina", "agent draft"); + const first = new TaskDraftStore(storage); + expect(first.set("task-a", { input: "한글 초안", owner: "kai" })).toBe(true); + expect(first.set("task-b", { input: "different task" })).toBe(true); + first.setScope("all"); + const next = new TaskDraftStore(storage); + expect(next.get("task-a")).toEqual({ input: "한글 초안", owner: "kai" }); + expect(next.get("task-b")).toEqual({ input: "different task" }); + expect(next.scope()).toBe("all"); + expect(storage.getItem("lina.draft.v1.lina")).toBe("agent draft"); + next.delete("task-a"); + expect(new TaskDraftStore(storage).get("task-a")).toBeUndefined(); +}); +test("failed persistence retains an in-memory draft and flushes before leaving", () => { + const storage = memory(); + let fail = true; + const store = new TaskDraftStore({ + ...storage, + setItem(key, value) { + if (fail) throw Error("quota"); + storage.setItem(key, value); + }, + }); + expect(store.set("task-a", { input: "keep me" })).toBe(false); + expect(store.get("task-a")?.input).toBe("keep me"); + expect(store.flush()).toBe(false); + fail = false; + expect(store.flush()).toBe(true); + expect(new TaskDraftStore(storage).get("task-a")?.input).toBe("keep me"); +}); +test("malformed device data never becomes task input", () => { + const storage = memory(); + storage.setItem("lina.task-draft.v1.task-a", '{"input":42}'); + storage.setItem("lina.task-scope.v1", "unexpected"); + const store = new TaskDraftStore(storage); + expect(store.get("task-a")).toBeUndefined(); + expect(store.scope()).toBe("selected"); +}); + +test("discarding a never-saved draft permits leaving without a storage service", () => { + const store = new TaskDraftStore(); + expect(store.set("task-a", { input: "temporary" })).toBe(false); + expect(store.set("task-a", { input: "" })).toBe(true); + expect(store.flush()).toBe(true); + expect(store.get("task-a")).toBeUndefined(); +}); diff --git a/packages/lina-web/test/task-ui.browser.mjs b/packages/lina-web/test/task-ui.browser.mjs new file mode 100644 index 0000000..3d6c01b --- /dev/null +++ b/packages/lina-web/test/task-ui.browser.mjs @@ -0,0 +1,159 @@ +// Isolated renderer smoke. Run with Bun and an installed Playwright module. +import assert from "node:assert/strict"; + +const { chromium } = await import( + process.env.LINA_PLAYWRIGHT_MODULE ?? "playwright" +); +const client = new URL("../../lina-ui/client/", import.meta.url); +const source = await Bun.file(new URL("index.html", client)).text(); +const nav = source.match( + /]*\bid="task-pane"[^>]*>[\s\S]*?<\/nav>/, +)?.[0]; +const detail = source.match( + /]*\bid="task-dialog"[^>]*>[\s\S]*?<\/dialog>/, +)?.[0]; +assert.ok(nav && detail, "task pane and detail markup exist"); +const built = await Bun.build({ + entrypoints: [new URL("task-view.ts", client).pathname], + target: "browser", +}); +assert.ok(built.success, String(built.logs)); +const css = ( + await Promise.all( + [ + "tokens.css", + "base.css", + "sidebar.css", + "chat.css", + "composer.css", + "responsive.css", + "settings.css", + "tasks.css", + ].map((name) => Bun.file(new URL(name, client)).text()), + ) +).join("\n"); +const bootstrap = ` +import { installTasks } from '/task-view.js'; +const tasks = ['lina', 'kai'].map((ownerAgentId, index) => ({ + id: 'task-' + (index + 1), threadId: 'native-' + (index + 1), ownerAgentId, + title: index ? '카이의 로그인 오류 수정' : '리나의 긴 한글 작업 제목과 회귀 확인', + cwd: '/tmp/work', status: 'inProgress', revision: 3, model: 'fixture', + updatedAt: '2026-09-07T12:00:00Z', pendingApprovals: [] +})); +window.taskView = installTasks({ownerAgentId: 'lina', scheduler: () => () => {}, + request: async (path) => { + if (path === '/api/agents') return {agents: [{id:'lina', name:'리나'}, {id:'kai', name:'카이'}]}; + if (path === '/api/tasks') return {tasks}; + const task = tasks.find(task => path === '/api/tasks/' + task.id); + if (!task) throw Error(path); + return {task, thread: null}; + } +}); +await window.taskView.showList(); +document.body.dataset.ready = 'true'; +`; +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/task-view.js") return new Response(built.outputs[0]); + if (path === "/bootstrap.js") + return new Response(bootstrap, { + headers: { "Content-Type": "text/javascript" }, + }); + return new Response( + `${detail}`, + { headers: { "Content-Type": "text/html" } }, + ); + }, +}); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_CHROMIUM_PATH + ? { executablePath: process.env.LINA_CHROMIUM_PATH } + : {}), + args: ["--no-sandbox"], +}); +const errors = []; +const captures = []; +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(server.url.href); + await page.waitForFunction(() => document.body.dataset.ready === "true"); + assert.equal(await page.locator("#task-list button").count(), 1); + await page.locator("#task-scope").selectOption("all"); + await page.getByRole("button", { name: /카이의 로그인/ }).waitFor(); + await page.evaluate(() => { + document + .querySelector("#task-column") + .append(document.querySelector("#task-pane")); + document.documentElement.dataset.sidebarCollapsed = "true"; + }); + assert.equal(await page.locator("#task-list").isVisible(), true); + assert.equal(await page.locator("#add-task").isVisible(), true); + assert.equal(await page.locator("#task-reload").isVisible(), true); + await page.getByRole("button", { name: /리나의 긴 한글/ }).click(); + await page.waitForFunction( + () => !document.querySelector("#task-interrupt").disabled, + ); + await page.locator("#task-input").fill("리나 작업에만 남길 초안"); + assert.equal( + await page.locator("#task-open-codex").getAttribute("href"), + "codex://threads/native-1", + ); + await page.locator("#task-close").click(); + await page.locator("#task-dialog").waitFor({ state: "hidden" }); + await page.getByRole("button", { name: /카이의 로그인/ }).click(); + await page.waitForFunction( + () => !document.querySelector("#task-interrupt").disabled, + ); + assert.equal(await page.locator("#task-input").inputValue(), ""); + await page.locator("#task-close").click(); + await page.locator("#task-dialog").waitFor({ state: "hidden" }); + if (process.env.TASK_UI_CAPTURE) + captures.push({ + name: "desktop-list", + data: (await page.screenshot()).toString("base64"), + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: /리나의 긴 한글/ }).click(); + await page.waitForFunction( + () => !document.querySelector("#task-interrupt").disabled, + ); + assert.equal( + await page.locator("#task-input").inputValue(), + "리나 작업에만 남길 초안", + ); + assert.equal( + await page.getByRole("button", { name: "담당 넘기기" }).isVisible(), + true, + ); + const bounds = await page.locator("#task-dialog").boundingBox(); + assert.equal(bounds.width, 390); + assert.equal(bounds.height, 844); + if (process.env.TASK_UI_CAPTURE) + captures.push({ + name: "mobile-detail", + data: (await page.screenshot()).toString("base64"), + }); + await page.keyboard.press("Escape"); + await page.locator("#task-dialog").waitFor({ state: "hidden" }); + assert.equal(await page.locator("#task-scope").inputValue(), "all"); + assert.equal(await page.locator("#task-list button").count(), 2); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + result: "pass", + viewports: ["1440x900", "390x844"], + errors, + captures, + }), + ); +} finally { + await browser.close(); + await server.stop(true); +} diff --git a/packages/lina-web/test/task-ui.test.ts b/packages/lina-web/test/task-ui.test.ts index 44eeb90..46dc0ff 100644 --- a/packages/lina-web/test/task-ui.test.ts +++ b/packages/lina-web/test/task-ui.test.ts @@ -7,13 +7,14 @@ import { parseTaskDetail, parseTaskList, taskDeepLink, -} from "../client/task-view.ts"; +} from "../../lina-ui/client/task-view.ts"; class Node { id = ""; className = ""; type = ""; hidden = false; + scrollTop = 0; attributes = new Map(); setAttribute(k: string, v: string) { this.attributes.set(k, v); @@ -162,6 +163,11 @@ function fixture() { root.append(node); return node; }; + const tasksDialog = make("tasks-dialog", "section"); + Object.defineProperty(tasksDialog, "showModal", { value: undefined }); + Object.defineProperty(tasksDialog, "close", { value: undefined }); + tasksDialog.hidden = true; + make("task-list"); make("tasks-status", "p"); make("add-task", "button"); @@ -199,7 +205,8 @@ function fixture() { document: { location: { href: "http://fixture/?agent=lina" }, createElement: (tag: string) => new Node(tag), - getElementById: find, + getElementById: (id: string) => + [root, ...walk(root)].find((item) => item.id === id) ?? null, }, }; } @@ -966,3 +973,313 @@ test("live detail fills new turn history even when its summary revision was alre expect(f.find("task-history").textContent).toContain("새로 보낸 메시지"); view.close(); }); + +const otherTask = { + ...sampleTask, + id: "task-2", + threadId: "other-native-thread", + ownerAgentId: "kai", + title: "카이 작업", +}; +const taskAgents = { + agents: [ + { id: "lina", name: "리나" }, + { id: "kai", name: "카이" }, + ], +}; + +test("task list defaults to selected owner and all scope follows actual owners", async () => { + const f = fixture(); + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + request: async (path) => { + if (path === "/api/agents") return taskAgents; + expect(path).toBe("/api/tasks"); + return { + tasks: [ + sampleTask, + otherTask, + { + ...sampleTask, + id: "unknown", + ownerAgentId: "gone", + title: "남은 작업", + }, + ], + }; + }, + }); + await view.showList(); + expect(f.find("tasks-dialog").hidden).toBe(false); + expect(f.find("task-list").querySelectorAll("button")).toHaveLength(1); + expect(f.find("task-scope").textContent).toContain("담당: 리나"); + await view.setScope("all"); + expect(f.find("task-list").querySelectorAll("button")).toHaveLength(3); + expect(f.find("task-open-task-2").textContent).toContain("카이"); + expect(f.find("task-open-unknown").textContent).toContain("담당 미지정"); + await view.setOwner("kai"); + expect(f.find("task-scope").value).toBe("all"); + expect(f.find("task-list").querySelectorAll("button")).toHaveLength(3); + f.find("task-scope").value = "selected"; + await f.find("task-scope").fire("change"); + expect(f.find("task-list").querySelectorAll("button")).toHaveLength(1); + expect(f.find("task-list").textContent).toContain("카이 작업"); + view.close(); +}); + +test("detail back retains list scope scroll and task-specific input and owner drafts", async () => { + const f = fixture(); + const events: string[] = []; + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + onShowList: () => events.push("list"), + onOpen: (id) => events.push(id), + request: async (path) => { + if (path === "/api/agents") return taskAgents; + if (path === "/api/tasks") return { tasks: [sampleTask, otherTask] }; + return { + task: path.endsWith("task-2") ? otherTask : sampleTask, + thread: null, + }; + }, + }); + await view.setScope("all"); + await view.showList(); + f.find("task-list").scrollTop = 120; + await view.open("task-1"); + f.find("task-input").value = "첫 작업 초안"; + f.find("task-owner").value = "kai"; + await view.refresh(); + await view.showList(); + expect(f.dialog.open).toBe(false); + expect(f.find("task-list").scrollTop).toBe(120); + expect(f.find("task-scope").value).toBe("all"); + await view.open("task-2"); + expect(f.find("task-input").value).toBe(""); + f.find("task-input").value = "둘째 초안"; + await view.open("task-1"); + expect(f.find("task-input").value).toBe("첫 작업 초안"); + expect(f.find("task-owner").value).toBe("kai"); + expect(f.find("task-open-codex").attributes.get("href")).toBe( + "codex://threads/thread-abc", + ); + await f.find("task-close").fire("click"); + expect(events.at(-1)).toBe("list"); + expect(events).toContain("task-2"); + view.close(); +}); + +test("owner switch discards stale list and agent-directory responses", async () => { + const f = fixture(); + const oldTasks = Promise.withResolvers(); + const oldAgents = Promise.withResolvers(); + let lists = 0; + let agents = 0; + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + request: async (path) => { + if (path === "/api/agents") + return ++agents === 1 ? oldAgents.promise : taskAgents; + return ++lists === 1 + ? oldTasks.promise + : { tasks: [sampleTask, otherTask] }; + }, + }); + const old = view.refresh(); + await view.setOwner("kai"); + oldTasks.resolve({ tasks: [] }); + oldAgents.resolve({ agents: [{ id: "kai", name: "오래된 이름" }] }); + await old; + expect(f.find("task-list").textContent).toContain("카이 작업"); + expect(f.find("task-scope").textContent).not.toContain("오래된 이름"); + view.close(); +}); + +test("scope switch invalidates pending detail and a late mutation without losing drafts", async () => { + const f = fixture(); + const detail = Promise.withResolvers(); + const mutation = Promise.withResolvers(); + const sent = Promise.withResolvers(); + let reads = 0; + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + request: async (path, method = "GET") => { + if (method === "POST") { + sent.resolve(); + return mutation.promise; + } + if (path === "/api/agents") return taskAgents; + if (path === "/api/tasks") return { tasks: [sampleTask, otherTask] }; + return ++reads === 1 + ? detail.promise + : { task: sampleTask, thread: null }; + }, + }); + const opening = view.open("task-1"); + await view.setScope("all"); + detail.resolve({ task: { ...sampleTask, title: "늦은 상세" }, thread: null }); + await opening; + expect(f.dialog.open).toBe(false); + await view.open("task-1"); + f.find("task-input").value = "보류한 입력"; + const sending = f.find("task-send").fire("click"); + await sent.promise; + await view.setScope("selected"); + mutation.resolve({ + task: { ...sampleTask, title: "늦은 응답", revision: 20 }, + }); + await sending; + await view.open("task-1"); + expect(f.find("task-heading").textContent).toBe("로그 정리"); + expect(f.find("task-input").value).toBe("보류한 입력"); + view.close(); +}); + +test("refresh during detail load preserves the active detail and owner selection", async () => { + const f = fixture(); + const gate = Promise.withResolvers(); + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + request: async (path) => { + if (path === "/api/agents") return taskAgents; + if (path === "/api/tasks") return { tasks: [sampleTask] }; + return gate.promise; + }, + }); + const opening = view.open("task-1"); + await view.refresh(); + gate.resolve({ task: sampleTask, thread: null }); + await opening; + expect(f.find("task-heading").textContent).toBe("로그 정리"); + expect(f.find("task-owner").value).toBe("lina"); + expect(f.find("task-interrupt").disabled).toBe(false); + view.close(); +}); + +test("inline task pane needs no list dialog and closes detail before asynchronous native close events", async () => { + const f = fixture(); + f.find("tasks-dialog").id = "task-pane"; + const callbacks: string[] = []; + f.dialog.close = () => { + f.dialog.open = false; + }; + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + onShowList: () => callbacks.push("list"), + request: async (path) => { + if (path === "/api/agents") return taskAgents; + if (path === "/api/tasks") return { tasks: [sampleTask] }; + return { task: sampleTask, thread: null }; + }, + }); + await view.showList(); + expect(f.find("task-pane").hidden).toBe(false); + await view.open("task-1"); + await view.showList(); + expect(callbacks).toEqual(["list", "list"]); + await view.open("task-1"); + await f.dialog.fire("close"); + expect(f.dialog.hidden).toBe(false); + expect(f.dialog.open).toBe(true); + expect(callbacks).toEqual(["list", "list"]); + view.close(); +}); + +test("create uses the newly selected owner even when all tasks are shown", async () => { + const f = fixture(); + const created = Promise.withResolvers(); + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + request: async (path, method = "GET", body) => { + if (method === "POST") { + created.resolve(body); + return { task: otherTask }; + } + if (path === "/api/agents") return taskAgents; + return { tasks: [] }; + }, + }); + await view.setScope("all"); + await view.setOwner("kai"); + await f.find("add-task").fire("click"); + f.find("task-title").value = "정리"; + f.find("task-cwd").value = "/tmp/work"; + f.find("task-prompt").value = "파일 정리"; + await f.find("task-create-submit").fire("click"); + expect(await created.promise).toMatchObject({ ownerAgentId: "kai" }); + view.close(); +}); + +test("live handover adds a newly discovered owner without dropping an edited owner draft", async () => { + const f = fixture(); + let task = { ...sampleTask }; + let directory = { agents: [{ id: "lina", name: "리나" }] }; + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + request: async (path) => { + if (path === "/api/agents") return directory; + if (path === "/api/tasks") return { tasks: [task] }; + return { task, thread: null }; + }, + }); + await view.open("task-1"); + directory = taskAgents; + task = { ...task, ownerAgentId: "kai", revision: 4 }; + await view.sync(); + expect( + f + .find("task-owner") + .querySelectorAll("option") + .map((option) => option.value), + ).toContain("kai"); + expect(f.find("task-owner").value).toBe("kai"); + f.find("task-owner").value = "lina"; + task = { ...task, revision: 5 }; + await view.sync(); + expect(f.find("task-owner").value).toBe("lina"); + view.close(); +}); + +test("editing a restored task while its detail loads preserves the edited owner", async () => { + const f = fixture(); + const values = new Map([ + [ + "lina.task-draft.v1.task-1", + JSON.stringify({ input: "saved task draft", owner: "kai" }), + ], + ]); + const pending = Promise.withResolvers(); + const view = installTasks({ + ownerAgentId: "lina", + document: f.document, + storage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + }, + request: async (path) => + path === "/api/agents" ? taskAgents : pending.promise, + }); + const opening = view.open("task-1"); + f.find("task-input").value = "edited while loading"; + await f.find("task-input").fire("input"); + pending.resolve({ task: sampleTask, thread: null }); + await opening; + expect(f.find("task-owner").value).toBe("kai"); + expect(JSON.parse(values.get("lina.task-draft.v1.task-1") ?? "null")).toEqual( + { input: "edited while loading", owner: "kai" }, + ); + view.close(); +}); diff --git a/packages/lina-web/test/theme.test.ts b/packages/lina-web/test/theme.test.ts index a0389fa..1554e12 100644 --- a/packages/lina-web/test/theme.test.ts +++ b/packages/lina-web/test/theme.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { readTheme, saveTheme } from "../client/theme.ts"; +import { readTheme, saveTheme } from "../../lina-ui/client/theme.ts"; test("appearance defaults to dark and preserves an explicit light preference", () => { let value: string | null = null; diff --git a/packages/lina-web/test/tool-label.test.ts b/packages/lina-web/test/tool-label.test.ts index 41e6c76..543dc39 100644 --- a/packages/lina-web/test/tool-label.test.ts +++ b/packages/lina-web/test/tool-label.test.ts @@ -4,7 +4,7 @@ import { COMMAND_DETAIL_MAX_CHARS, toolLabel, toolSummary, -} from "../client/tool-label.ts"; +} from "../../lina-ui/client/tool-label.ts"; test("tool labels map known tools and bound unknown names", () => { for (const name of [ diff --git a/packages/lina-web/test/tool-view.test.ts b/packages/lina-web/test/tool-view.test.ts index f46630d..3db9687 100644 --- a/packages/lina-web/test/tool-view.test.ts +++ b/packages/lina-web/test/tool-view.test.ts @@ -1,10 +1,10 @@ import { expect, test } from "bun:test"; -import type { ChatMessage } from "../client/model.ts"; +import type { ChatMessage } from "../../lina-client/src/model.ts"; import { createToolNode, type ToolElement, updateToolNode, -} from "../client/tool-view.ts"; +} from "../../lina-ui/client/tool-view.ts"; class FakeElement implements ToolElement { readonly children: FakeElement[] = []; diff --git a/scripts/qa/ui-agent-list.mjs b/scripts/qa/ui-agent-list.mjs new file mode 100644 index 0000000..85959ff --- /dev/null +++ b/scripts/qa/ui-agent-list.mjs @@ -0,0 +1,447 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +// Bun + an installed Playwright; isolated component host, no model or user data. +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const root = fileURLToPath(new URL("../../", import.meta.url)); +const output = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-agent-list-evidence"; +await mkdir(output, { recursive: true }); +const bundle = await Bun.build({ + entrypoints: ["agent-list-qa"], + target: "browser", + define: { "process.env.NODE_ENV": '"production"' }, + plugins: [ + { + name: "agent-list-fixture", + setup(build) { + build.onResolve({ filter: /^agent-list-qa$/ }, () => ({ + path: "agent-list-qa", + namespace: "fixture", + })); + build.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ + loader: "ts", + resolveDir: root, + contents: ` +import { createAgentListView } from ${JSON.stringify(`${root}packages/lina-ui/client/agent-list.ts`)}; +import { AgentListModel } from ${JSON.stringify(`${root}packages/lina-client/src/agent-list.ts`)}; +import ${JSON.stringify(`${root}packages/lina-ui/client/styles.css`)}; +const model = new AgentListModel("lina"); +const profiles = [ + { id: "lina", name: "Lina", role: "개인 에이전트", avatarId: null }, + { id: "writer", name: "긴 이름 작가", role: "글쓰기", avatarId: null }, + { id: "research", name: "Research", role: "자료 조사", avatarId: null }, +]; +const calls = { selected: [], menus: [], retries: 0 }; +const render = createAgentListView(model, document.querySelector("#other-agents"), document.querySelector("#agent-list-status"), { + select(id) { calls.selected.push(id); }, + menu(id, opener) { calls.menus.push({ id, connected: opener.isConnected, label: opener.getAttribute("aria-label"), className: opener.className }); }, + retry() { calls.retries++; }, +}); +const filter = document.querySelector("#agent-filter"); +filter.addEventListener("input", () => { model.setFilter(filter.value); render(); }); +render(); +window.fixture = { model, profiles, calls, render }; +`, + })); + }, + }, + ], +}); +assert.ok(bundle.success, bundle.logs.join("\n")); +const assets = new Map( + bundle.outputs.map((asset) => [asset.path.replace(/^\.\//, "/"), asset]), +); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/") + return new Response( + ``, + { headers: { "content-type": "text/html" } }, + ); + const asset = assets.get(path); + return asset + ? new Response(asset) + : new Response("Not found", { status: 404 }); + }, +}); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + hasTouch: true, +}); +await context.tracing.start({ screenshots: true, snapshots: true }); +const page = await context.newPage(); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +const results = []; +const row = (id) => page.locator(`[data-agent-id="${id}"] .agent-link`); +async function reset() { + await page.goto(server.url.href); + await page.waitForFunction(() => !!window.fixture); + await page.evaluate(() => { + fixture.model.replace(fixture.profiles); + fixture.render(); + }); +} +async function check(name, run) { + try { + await reset(); + await run(); + results.push({ name, pass: true }); + } catch (error) { + results.push({ name, pass: false, error: String(error) }); + await page.screenshot({ path: `${output}/failure-${results.length}.png` }); + } +} +const touch = { + pointerType: "touch", + pointerId: 1, + isPrimary: true, + clientX: 25, + clientY: 25, +}; +try { + await check("filtering a pressed row cancels its menu", async () => { + await page.clock.install(); + await row("writer").dispatchEvent("pointerdown", touch); + await page.locator("#agent-filter").fill("Research"); + await page.clock.runFor(600); + assert.deepEqual(await page.evaluate(() => fixture.calls.menus), []); + }); + await check("scroll cancels a pending touch menu", async () => { + await page.clock.install(); + await row("writer").dispatchEvent("pointerdown", touch); + await page.locator(".sidebar-content").dispatchEvent("scroll"); + await page.clock.runFor(600); + assert.deepEqual(await page.evaluate(() => fixture.calls.menus), []); + }); + await check( + "keyboard, selection, menu openers and stable retained rows", + async () => { + await row("lina").focus(); + await page.keyboard.press("ArrowDown"); + assert.equal( + await row("writer").evaluate((node) => node === document.activeElement), + true, + ); + await page.keyboard.press("End"); + assert.equal( + await row("research").evaluate( + (node) => node === document.activeElement, + ), + true, + ); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("Home"); + await page.keyboard.press("Enter"); + assert.deepEqual(await page.evaluate(() => fixture.calls.selected), [ + "lina", + ]); + await page.keyboard.press("Shift+F10"); + await row("writer").dispatchEvent("contextmenu"); + await page.locator('[data-agent-id="research"] .agent-more').click(); + assert.deepEqual( + await page.evaluate(() => + fixture.calls.menus.map(({ id, connected }) => ({ id, connected })), + ), + [ + { id: "lina", connected: true }, + { id: "writer", connected: true }, + { id: "research", connected: true }, + ], + ); + assert.ok( + await page.evaluate(() => + fixture.calls.menus.every( + (call) => + call.className.includes("agent-more") && + call.label.endsWith(" 메뉴"), + ), + ), + ); + await row("writer").focus(); + const stable = await row("writer").elementHandle(); + await page.evaluate(() => { + fixture.model.replace([...fixture.profiles].reverse()); + fixture.model.setCurrent("writer"); + fixture.model.setDraft("writer", "업데이트된 초안"); + fixture.render(); + }); + assert.equal( + await stable.evaluate( + (node) => + node === + document.querySelector('[data-agent-id="writer"] .agent-link') && + node === document.activeElement, + ), + true, + ); + await page.locator("#agent-filter").fill("글쓰기"); + assert.equal(await row("writer").isVisible(), true); + assert.equal(await row("lina").isVisible(), false); + }, + ); + await check("long press, move, up, cancel and leave", async () => { + await page.clock.install(); + for (const type of [ + "pointermove", + "pointerup", + "pointercancel", + "pointerleave", + ]) { + await row("writer").dispatchEvent("pointerdown", touch); + // Browsers emit out before leave; React derives onPointerLeave from out. + if (type === "pointerleave") + await row("writer").dispatchEvent("pointerout", touch); + await row("writer").dispatchEvent(type, { + ...touch, + clientY: type === "pointermove" ? 50 : 25, + }); + await page.clock.runFor(600); + assert.deepEqual( + await page.evaluate(() => fixture.calls.menus), + [], + type, + ); + } + assert.deepEqual(await page.evaluate(() => fixture.calls.menus), []); + await row("writer").dispatchEvent("pointerdown", touch); + await page.clock.runFor(550); + await row("writer").dispatchEvent("pointerup", touch); + await row("writer").dispatchEvent("click", { detail: 1 }); + assert.deepEqual( + await page.evaluate(() => fixture.calls.menus.map((call) => call.id)), + ["writer"], + ); + assert.deepEqual(await page.evaluate(() => fixture.calls.selected), []); + await row("writer").click(); + assert.deepEqual(await page.evaluate(() => fixture.calls.selected), [ + "writer", + ]); + }); + await check( + "status, row details, unread and confirmation precedence", + async () => { + await page.evaluate(() => { + fixture.model.updateSummary({ + agentId: "writer", + available: true, + sessionId: "session", + revision: 1, + latestMessage: { + entryId: "a", + seq: 1, + role: "assistant", + text: "저장된 답변", + timestamp: "2026-08-05T12:00:00Z", + }, + confirmationCount: 0, + running: true, + }); + fixture.render(); + }); + const writer = page.locator('[data-agent-id="writer"]'); + assert.equal( + await writer.locator("strong").getAttribute("title"), + "긴 이름 작가", + ); + assert.equal( + await writer.locator("small").getAttribute("title"), + "저장된 답변", + ); + assert.equal( + await writer.locator("time").getAttribute("datetime"), + "2026-08-05T12:00:00Z", + ); + assert.equal(await writer.locator("time").isVisible(), true); + assert.equal(await writer.locator(".agent-working").isVisible(), true); + assert.equal( + await row("writer").getAttribute("aria-label"), + "긴 이름 작가 · 글쓰기 · 저장된 답변 · 작업 중", + ); + await page.evaluate(() => { + fixture.model.markVisible("writer", "session", 1); + const summary = fixture.model.summary("writer"); + fixture.model.updateSummary({ + ...summary, + revision: 2, + latestMessage: { ...summary.latestMessage, seq: 2 }, + confirmationCount: 2, + }); + fixture.model.summariesFailed(); + fixture.render(); + }); + assert.equal( + await writer.locator("small").textContent(), + "확인 필요 · 2건", + ); + assert.equal(await writer.locator(".agent-unread").isVisible(), true); + assert.equal(await writer.locator(".agent-working").isVisible(), false); + assert.ok( + (await writer.getAttribute("class")).includes("needs-confirmation"), + ); + assert.equal( + await row("writer").getAttribute("aria-label"), + "긴 이름 작가 · 글쓰기 · 확인 필요 · 2건 · 읽지 않은 메시지 · 요약 새로 고침 실패 · 작업 중", + ); + const status = page.locator("#agent-list-status"); + assert.ok( + (await status.textContent()).includes( + "일부 대화 요약을 새로 불러오지 못했어요.", + ), + ); + await status.getByRole("button", { name: "다시 시도" }).click(); + assert.equal(await page.evaluate(() => fixture.calls.retries), 1); + await page.screenshot({ path: `${output}/mobile-states.png` }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.screenshot({ path: `${output}/desktop-states.png` }); + await page.evaluate(() => { + fixture.model.fail(); + fixture.render(); + }); + assert.ok( + (await status.textContent()).includes( + "에이전트 목록을 불러오지 못했어요.", + ), + ); + await page.evaluate(() => { + fixture.model.status = "loading"; + fixture.render(); + }); + assert.equal( + await page.locator("#other-agents").getAttribute("aria-busy"), + "true", + ); + assert.ok( + (await status.textContent()).includes("에이전트를 불러오는 중…"), + ); + await page.evaluate(() => { + fixture.model.replace([]); + fixture.render(); + }); + assert.ok( + (await status.textContent()).includes("아직 에이전트가 없어요."), + ); + await page.locator("#agent-filter").fill("없음"); + assert.ok( + (await status.textContent()).includes( + "이름이나 역할이 일치하는 에이전트가 없어요.", + ), + ); + assert.equal(await status.getAttribute("role"), "status"); + assert.equal(await status.getAttribute("aria-live"), "polite"); + }, + ); + await check( + "avatar source changes, load failure and Unicode fallback", + async () => { + const avatar = page.locator('[data-agent-id="writer"] .avatar'); + assert.equal(await avatar.textContent(), "긴"); + await page.route("**/api/avatars/**", (route) => + route.fulfill({ + contentType: "image/svg+xml", + body: route.request().url().endsWith("valid%2Fimage") + ? '' + : "invalid image", + }), + ); + const updateAvatar = async (avatarId, name = "긴 이름 작가") => + page.evaluate( + ({ avatarId, name }) => { + fixture.model.replace( + fixture.profiles.map((profile) => + profile.id === "writer" + ? { ...profile, avatarId, name } + : profile, + ), + ); + fixture.render(); + }, + { avatarId, name }, + ); + await updateAvatar("valid/image"); + await avatar.locator("img").waitFor({ state: "visible" }); + assert.ok( + await avatar + .locator("img") + .evaluate((node) => node.complete && node.naturalWidth > 0), + ); + assert.equal(await avatar.textContent(), ""); + await updateAvatar("broken"); + await avatar.locator("img").waitFor({ state: "detached" }); + await page.waitForFunction( + () => + document.querySelector('[data-agent-id="writer"] .avatar') + .textContent === "긴", + ); + await updateAvatar(null, " 𠮷田 "); + assert.equal(await avatar.textContent(), "𠮷"); + assert.equal(await avatar.getAttribute("aria-hidden"), "true"); + await updateAvatar(null, " "); + assert.equal(await avatar.textContent(), "?"); + await updateAvatar("valid/image"); + await avatar.locator("img").waitFor({ state: "visible" }); + }, + ); + await check( + "navigation notice recovers through the next normal render", + async () => { + const status = page.locator("#agent-list-status"); + await page.evaluate(() => + fixture.render.notice( + "대화를 열지 못했어요. 에이전트를 다시 선택해주세요.", + ), + ); + assert.equal( + await status.locator("span").textContent(), + "대화를 열지 못했어요. 에이전트를 다시 선택해주세요.", + ); + assert.equal(await status.isVisible(), true); + assert.equal( + await status.getByRole("button", { name: "다시 시도" }).isVisible(), + false, + ); + await row("writer").click(); + await page.evaluate(() => { + fixture.model.setCurrent("writer"); + fixture.render(); + }); + assert.equal(await status.isVisible(), false); + assert.equal(await row("writer").getAttribute("aria-current"), "true"); + await page.evaluate(() => { + fixture.render.notice("선택을 다시 확인해주세요."); + fixture.model.fail(); + fixture.render(); + }); + assert.ok( + (await status.textContent()).includes( + "에이전트 목록을 불러오지 못했어요.", + ), + ); + await status.getByRole("button", { name: "다시 시도" }).click(); + assert.equal(await page.evaluate(() => fixture.calls.retries), 1); + }, + ); + assert.deepEqual(errors, []); +} finally { + await context.tracing.stop({ path: `${output}/trace.zip` }); + await browser.close(); + await server.stop(true); + await writeFile( + `${output}/results.json`, + JSON.stringify({ results, errors }, null, 2), + ); +} +console.log(JSON.stringify({ results, errors }, null, 2)); +if (results.some((result) => !result.pass)) process.exitCode = 1; diff --git a/scripts/qa/ui-components.mjs b/scripts/qa/ui-components.mjs new file mode 100644 index 0000000..56bc26e --- /dev/null +++ b/scripts/qa/ui-components.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; + +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const output = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-ui-components"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +try { + await page.goto(process.env.LINA_QA_URL ?? "http://127.0.0.1:18150"); + await page.locator("#other-agents .agent-link").first().waitFor(); + for (const [trigger, menu] of [ + ["#account-button", "#account-menu"], + ["#other-agents .agent-more", "#agent-actions"], + ]) { + for (const key of ["Tab", "Shift+Tab"]) { + const button = page.locator(trigger).first(); + await button.focus(); + await page.keyboard.press(key); + await page.evaluate(() => { + window.expectedTabTarget = document.activeElement; + }); + await button.click(); + await page.locator(menu).waitFor(); + await page.keyboard.press(key); + await page.locator(menu).waitFor({ state: "hidden", timeout: 2000 }); + await page.waitForFunction( + () => document.activeElement === window.expectedTabTarget, + undefined, + { timeout: 2000 }, + ); + } + } + await page.locator("#account-button").focus(); + await page.keyboard.press("ArrowUp"); + await page + .locator("#account-menu") + .waitFor({ state: "visible", timeout: 3000 }); + await page.waitForFunction( + () => document.activeElement?.id === "open-shortcuts", + ); + await page.keyboard.press("Home"); + await page.waitForFunction( + () => document.activeElement?.id === "open-settings", + ); + await page.keyboard.press("End"); + await page.waitForFunction( + () => document.activeElement?.id === "open-shortcuts", + ); + await page.keyboard.press("Escape"); + await page.waitForFunction( + () => document.activeElement?.id === "account-button", + ); + await page.locator("#account-button").click(); + await page.locator("#account-menu").waitFor(); + await page.locator("#account-button").click(); + await page + .locator("#account-menu") + .waitFor({ state: "hidden", timeout: 2000 }); + await page.locator("#account-button").click(); + await page.locator("#open-settings").click(); + await page.locator("#settings-dialog").waitFor(); + await page.keyboard.press("Escape"); + await page.locator("#settings-dialog").waitFor({ state: "hidden" }); + await page.locator("#message").fill("컴포넌트 전환 후에도 남아야 하는 초안"); + const more = page.locator("#other-agents .agent-more").first(); + await more.click(); + await page.locator("#agent-actions").waitFor(); + await page.keyboard.press("End"); + await page.waitForFunction(() => document.activeElement?.id === "open-mind"); + await page.keyboard.press("Escape"); + await page.waitForFunction( + () => + document.querySelector("#other-agents .agent-more") === + document.activeElement, + ); + await more.click(); + await page.locator("#agent-actions").waitFor(); + await page.locator("#message").click(); + await page.locator("#agent-actions").waitFor({ state: "hidden" }); + assert.equal( + await page.locator("#message").inputValue(), + "컴포넌트 전환 후에도 남아야 하는 초안", + ); + await page.locator("#current-agent-settings").click(); + await page.locator("#edit-agent").click(); + await page.locator("#agent-dialog").waitFor(); + await page.waitForFunction(() => + document.querySelector("#agent-dialog")?.contains(document.activeElement), + ); + assert.equal( + await page + .locator("#agent-dialog") + .evaluate((e) => e.contains(document.activeElement)), + true, + ); + await page.keyboard.press("Escape"); + assert.deepEqual(errors, []); + console.log( + "PASS shared menus: keyboard entry, roving focus, escape, dialog handoff, outside dismissal, draft retention", + ); +} finally { + await browser.close(); +} diff --git a/scripts/qa/ui-design.mjs b/scripts/qa/ui-design.mjs new file mode 100644 index 0000000..0bcb304 --- /dev/null +++ b/scripts/qa/ui-design.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +// Geometry and actual-render evidence for the shared UI. Use the synthetic fixture. +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const base = process.env.LINA_QA_URL ?? "http://127.0.0.1:18150"; +const output = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-ui-design-review"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const errors = []; +const measurements = []; +page.on("pageerror", (error) => errors.push(error.message)); +try { + await page.goto(`${base}/?agent=lina`); + await page.locator("#other-agents .agent-row").first().waitFor(); + await page.locator("#messages .message").first().waitFor(); + const palette = await page.evaluate(() => { + const style = getComputedStyle(document.documentElement); + return Object.fromEntries( + ["--surface-main", "--surface-sidebar", "--input-bg"].map((key) => [ + key, + style.getPropertyValue(key).trim(), + ]), + ); + }); + await page.screenshot({ path: join(output, "desktop.png") }); + // The user-provided Codex reference has the darkest reading surface, then sidebar, + // then composer. Previous Lina implementation inverted the first two surfaces. + assert.equal( + palette["--surface-main"], + "#181818", + "reference: darkest reading surface", + ); + assert.equal( + palette["--surface-sidebar"], + "#212121", + "reference: raised navigation surface", + ); + for (const width of [1440, 1024, 768, 390, 320]) { + await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); + if (width < 768 && (await page.locator("#other-agents").isVisible())) + await page + .locator('#other-agents [data-agent-id="lina"] .agent-link') + .click(); + await page.locator("#message").fill("다음 주 여행 준비를 함께 정리해줘."); + const geometry = await page.evaluate(() => { + const rect = (selector) => { + const r = document.querySelector(selector).getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + right: r.right, + bottom: r.bottom, + }; + }; + return { + viewport: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + composer: rect(".composer"), + send: rect("#send"), + attach: rect("#attach-file"), + model: rect("#composer-model"), + header: rect(".chat-header"), + title: rect("#chat-agent-name"), + menu: rect("#current-agent-settings"), + }; + }); + measurements.push(geometry); + assert.ok( + geometry.scrollWidth <= width, + `no horizontal page overflow at ${width}`, + ); + assert.ok( + Math.abs(geometry.send.width - geometry.send.height) < 1, + "send has a circular icon target", + ); + assert.ok( + Math.abs( + geometry.send.y + + geometry.send.height / 2 - + (geometry.attach.y + geometry.attach.height / 2), + ) < 1, + "composer controls share a center line", + ); + assert.ok( + geometry.model.right <= geometry.send.x, + "model control does not overlap send", + ); + assert.ok( + geometry.composer.bottom <= (width < 768 ? 844 : 900), + "composer stays in viewport", + ); + assert.equal( + await page.locator("#attach-file svg").count(), + 1, + "attachment has a real shared-size SVG icon", + ); + if (width < 768) { + assert.ok( + geometry.send.width >= 44 && geometry.attach.width >= 44, + "mobile action targets >=44px", + ); + assert.ok( + geometry.title.right <= geometry.menu.x, + "title does not overlap header actions", + ); + } + await page.locator("#message").blur(); + await page.screenshot({ path: join(output, `conversation-${width}.png`) }); + } + await page.locator("#open-sidebar").click(); + await page.locator("#mobile-settings").click(); + await page.locator("#settings-dialog[open]").waitFor(); + for (const width of [390, 320]) { + await page.setViewportSize({ width, height: 844 }); + const tabs = await page + .locator('.settings-navigation [role="tab"] span') + .evaluateAll((nodes) => + nodes.map((node) => ({ + height: node.getBoundingClientRect().height, + line: Number.parseFloat(getComputedStyle(node).lineHeight), + })), + ); + assert.ok( + tabs.every((tab) => tab.height <= tab.line + 1), + `settings tab labels stay on one line at ${width}`, + ); + const modal = await page.locator("#settings-dialog").boundingBox(); + assert.ok( + modal && modal.x === 0 && modal.width === width, + "mobile settings use the full screen", + ); + } + await page.locator('[data-theme-choice="light"]').click(); + const surfaces = await page.evaluate(() => { + const bg = (selector) => + getComputedStyle(document.querySelector(selector)).backgroundColor; + return { + reading: bg(".workspace"), + composer: bg(".composer"), + taskInput: bg(".task-composer"), + taskBody: bg("#task-dialog"), + }; + }); + assert.notEqual( + surfaces.composer, + surfaces.reading, + "unfocused light composer remains distinguishable from reading surface", + ); + assert.notEqual( + surfaces.taskInput, + surfaces.taskBody, + "unfocused light task input remains distinguishable from its dialog", + ); + assert.deepEqual(errors, []); + console.log( + "PASS: reference surface hierarchy, composer alignment, icon controls and responsive containment (5 viewports)", + ); +} finally { + await writeFile( + join(output, "geometry.json"), + JSON.stringify({ measurements, errors }, null, 2), + ); + await browser.close(); +} diff --git a/scripts/qa/ui-desktop.mjs b/scripts/qa/ui-desktop.mjs new file mode 100644 index 0000000..7270270 --- /dev/null +++ b/scripts/qa/ui-desktop.mjs @@ -0,0 +1,314 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +// Run against test/fixtures/ui-desktop.ts only. No live model or user data. +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const base = process.env.LINA_QA_URL ?? "http://127.0.0.1:18140"; +const output = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-ui-desktop-evidence"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + permissions: ["clipboard-read", "clipboard-write"], +}); +await context.tracing.start({ screenshots: true, snapshots: true }); +const page = await context.newPage(); +const errors = []; +const httpErrors = []; +page.on("response", (response) => { + if (response.status() >= 400) + httpErrors.push({ status: response.status(), url: response.url() }); +}); +page.on("pageerror", (error) => errors.push(error.message)); +const row = (id) => + page.locator(`#other-agents [data-agent-id="${id}"] .agent-link`); +const screen = (value) => + page.waitForFunction( + (value) => document.documentElement.dataset.screen === value, + value, + ); +const agent = (id) => + page.waitForFunction( + (id) => + new URL(location.href).searchParams.get("agent") === id && + document.querySelector("#messages .message"), + id, + ); +const input = page.locator("#message"); +const checks = []; +try { + await page.goto(`${base}/?agent=lina`); + await row("lina").waitFor(); + await page.locator("#send:not([disabled])").waitFor({ state: "hidden" }); + await page.locator("#messages .message").first().waitFor(); + const order = await page + .locator("#other-agents .agent-row") + .evaluateAll((rows) => rows.map((row) => row.dataset.agentId)); + assert.equal(order.length, 10); + await input.fill("Lina에 남기는 한글 초안"); + await page.locator("#conversation-scroll").evaluate((node) => { + node.scrollTop = 380; + }); + const position = await page + .locator("#conversation-scroll") + .evaluate((node) => node.scrollTop); + await row("kai").click(); + await agent("kai"); + await page.waitForFunction(() => + document.querySelector("#messages").textContent.includes("Kai의 대화"), + ); + assert.equal(await input.inputValue(), ""); + await input.fill("Kai의 별도 초안"); + await row("lina").click(); + await agent("lina"); + assert.equal(await input.inputValue(), "Lina에 남기는 한글 초안"); + assert.ok( + Math.abs( + (await page + .locator("#conversation-scroll") + .evaluate((node) => node.scrollTop)) - position, + ) < 3, + "reading anchor restored", + ); + assert.deepEqual( + await page + .locator("#other-agents .agent-row") + .evaluateAll((rows) => rows.map((row) => row.dataset.agentId)), + order, + ); + assert.equal(await page.evaluate(() => scrollY), 0); + checks.push( + "desktop in-page A→B→A preserves drafts, row order, reading anchor, one page", + ); + await page.locator("#file-picker").setInputFiles({ + name: "ui-proof.txt", + mimeType: "text/plain", + buffer: Buffer.from("isolated attachment proof"), + }); + await page.locator('#draft-files [data-attachment-state="ready"]').waitFor(); + await row("kai").click(); + await agent("kai"); + assert.equal(await input.inputValue(), "Kai의 별도 초안"); + await row("lina").click(); + await agent("lina"); + await page.locator('#draft-files [data-attachment-state="ready"]').waitFor(); + assert.ok( + (await page.locator("#draft-files").innerText()).includes("ui-proof.txt"), + ); + checks.push( + "uploaded attachment reference survives switching with its owner", + ); + await page + .locator('#draft-files button[aria-label="ui-proof.txt 첨부 제외"]') + .click(); + await input.fill("앞뒤"); + await input.evaluate((node) => node.setSelectionRange(1, 1)); + await page.evaluate(async () => { + await navigator.clipboard.writeText(" 붙여넣은 한글\n둘째 줄 "); + }); + await input.press("Control+V"); + await page.waitForFunction(() => + document.querySelector("#message").value.includes("둘째 줄"), + ); + assert.equal(await input.inputValue(), "앞 붙여넣은 한글\n둘째 줄 뒤"); + await input.press("Control+Z"); + assert.equal(await input.inputValue(), "앞뒤"); + checks.push( + "native Chromium clipboard text preserves selection, newline and Undo", + ); + await page.evaluate(async () => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 16; + canvas.getContext("2d").fillRect(0, 0, 16, 16); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + }); + await input.press("Control+V"); + await page + .locator('#draft-files [data-attachment-state="ready"] img') + .waitFor(); + await page.locator("#send").click(); + await page.waitForFunction(() => + document + .querySelector("#messages") + .textContent.includes("UI 검증용 합성 데이터"), + ); + await page.reload(); + await page.locator("#messages .message-files a").last().waitFor(); + checks.push( + "native Chromium PNG paste→upload→explicit send→reloaded conversation file", + ); + await page.screenshot({ path: join(output, "desktop.png") }); + await page.locator('.list-switch [data-screen="tasks"]').click(); + await page.locator("#toggle-task-column").click(); + await page.waitForFunction( + () => document.documentElement.dataset.layout === "dual", + ); + await page.locator("#task-column #task-list button").first().click(); + await page.locator("#task-dialog").waitFor({ state: "visible" }); + await page.waitForFunction(() => + document.querySelector("#task-heading").textContent.includes("로그인 오류"), + ); + await page.locator("#task-input").fill("재시작 뒤에도 남는 작업 초안"); + await page.locator("#task-dialog [data-close]").first().click(); + await page.locator("#task-scope").selectOption("all"); + await page.locator("#toggle-task-column").click(); + await page.goto(`${base}/?agent=lina&task=task-1`); + await page.locator("#task-dialog").waitFor({ state: "visible" }); + await page.reload(); + await page.locator("#task-dialog").waitFor({ state: "visible" }); + await page.waitForFunction(() => + document.querySelector("#task-heading").textContent.includes("로그인 오류"), + ); + assert.equal( + await page.locator("#task-input").inputValue(), + "재시작 뒤에도 남는 작업 초안", + ); + await page.locator("#task-dialog [data-close]").first().click(); + assert.equal(await page.locator("#task-scope").inputValue(), "all"); + checks.push("task input draft and all-owner filter survive document reload"); + assert.equal(new URL(page.url()).searchParams.has("task"), false); + checks.push( + "task direct link and reload preserve identity; closing returns to its list", + ); + checks.push("explicit task column uses existing detail and return controls"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('.mobile-navigation [data-screen="agents"]').click(); + await screen("agents"); + assert.equal(await page.locator("#conversation").isVisible(), false); + await page.locator("#agent-filter").fill("Kai"); + await row("kai").click(); + await screen("conversation"); + assert.equal(await input.inputValue(), "Kai의 별도 초안"); + assert.notEqual( + await page.evaluate(() => document.activeElement.id), + "message", + ); + assert.equal(await page.locator(".mobile-navigation").isVisible(), false); + await page.screenshot({ path: join(output, "mobile-conversation.png") }); + await page.locator("#open-sidebar").click(); + await screen("agents"); + assert.equal(await page.locator("#agent-filter").inputValue(), "Kai"); + await page.locator("#agent-filter").fill(""); + await page.screenshot({ path: join(output, "mobile-agents.png") }); + await page.locator("#mobile-settings").click(); + await page.locator("#settings-dialog").waitFor({ state: "visible" }); + await page.locator('[data-theme-choice="light"]').click(); + await page.locator("#settings-dialog [data-close]").first().click(); + await screen("agents"); + await page.goBack(); + assert.equal(await page.locator("#other-agents").isVisible(), true); + checks.push( + "390px list→chat→back restores filter/draft without opening keyboard; settings works", + ); + await page.locator("#agent-filter").fill(""); + await row("lina").click(); + await screen("conversation"); + await page.setViewportSize({ width: 390, height: 500 }); + const allow = page.getByRole("button", { name: "허용", exact: true }); + await allow.waitFor(); + const approveBox = await allow.boundingBox(); + const sendBox = await page.locator("#send").boundingBox(); + assert.ok( + approveBox && approveBox.y >= 0 && approveBox.y + approveBox.height <= 500, + ); + assert.ok(sendBox && sendBox.y >= 0 && sendBox.y + sendBox.height <= 500); + await page.getByRole("button", { name: "거절", exact: true }).click(); + await allow.waitFor({ state: "hidden" }); + await page.route("**/api/attachments", (route) => + route.fulfill({ status: 503, body: "synthetic failure" }), + ); + await input.fill("실패해도 남아야 하는 본문"); + await page.locator("#file-picker").setInputFiles({ + name: "retry.txt", + mimeType: "text/plain", + buffer: Buffer.from("retry content"), + }); + await page.locator('#draft-files [data-attachment-state="error"]').waitFor(); + assert.equal(await page.locator("#send").isDisabled(), true); + await page.locator("#open-sidebar").click(); + assert.equal(new URL(page.url()).searchParams.get("screen"), "conversation"); + await page.unroute("**/api/attachments"); + await page.getByRole("button", { name: "retry.txt 첨부 재시도" }).click(); + await page.locator('#draft-files [data-attachment-state="ready"]').waitFor(); + assert.equal(await input.inputValue(), "실패해도 남아야 하는 본문"); + await page.getByRole("button", { name: "retry.txt 첨부 제외" }).click(); + assert.equal( + await page.locator("#notice").isVisible(), + false, + "resolved attachment navigation warning clears", + ); + checks.push( + "390×500 reduced viewport keeps approval/send reachable; failed upload blocks send/navigation and retries safely", + ); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => { + document.documentElement.style.fontSize = "200%"; + }); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ); + await page.screenshot({ path: join(output, "mobile-text-200.png") }); + await page.evaluate(() => { + document.documentElement.style.fontSize = ""; + }); + for (const width of [320, 430, 768, 1024, 1280, 1920]) { + await page.setViewportSize({ width, height: 900 }); + await page.waitForFunction( + () => document.documentElement.scrollWidth <= innerWidth, + ); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ); + } + checks.push("320/430/768/1024/1280/1920 containment and light theme"); + if (process.env.LINA_QA_INTRO_URL) { + const intro = await context.newPage(); + intro.on("pageerror", (error) => errors.push(error.message)); + await intro.setViewportSize({ width: 390, height: 844 }); + await intro.goto(process.env.LINA_QA_INTRO_URL); + await intro.locator("#intro-setup").waitFor({ state: "visible" }); + await intro + .getByText("반가워요. 대화를 시작하려면 사용할 모델을 먼저 골라주세요.") + .waitFor(); + assert.equal(await intro.locator("#sidebar").isVisible(), false); + assert.equal(await intro.locator(".mobile-navigation").isVisible(), false); + assert.equal(await intro.locator("#composer-model").isVisible(), false); + await intro.screenshot({ path: join(output, "onboarding-mobile.png") }); + checks.push( + "first setup remains a sidebar-free conversation with the existing model connection recovery", + ); + await intro.close(); + } + assert.deepEqual(errors, []); + await writeFile( + join(output, "web-results.json"), + JSON.stringify({ checks, errors, httpErrors }, null, 2), + ); + console.log( + JSON.stringify({ passed: checks.length, checks, errors, output }), + ); +} catch (error) { + await page.screenshot({ path: join(output, "failure.png") }); + console.error( + JSON.stringify({ error: String(error), errors, url: page.url() }), + ); + throw error; +} finally { + await context.tracing.stop({ path: join(output, "web-trace.zip") }); + await browser.close(); +} diff --git a/scripts/qa/ui-model-combobox.mjs b/scripts/qa/ui-model-combobox.mjs new file mode 100644 index 0000000..c9ef1de --- /dev/null +++ b/scripts/qa/ui-model-combobox.mjs @@ -0,0 +1,528 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +// Bun builds the actual settings controller and React adapter; no provider calls. +const client = fileURLToPath( + new URL("../../packages/lina-ui/client/", import.meta.url), +); +const out = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-model-combobox"; +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const fixture = ` +import { createModelCombobox } from ${JSON.stringify(`${client}model-combobox.ts`)}; +import { installModelSettings } from ${JSON.stringify(`${client}model-settings.ts`)}; +const dialog = document.getElementById('settings-dialog'); +for (const panel of dialog.querySelectorAll('[role="tabpanel"]')) panel.hidden = panel.id !== 'settings-panel-models'; +window.saved = { revision: 0, profiles: [], roles: {}, agentRoles: {}, defaultProfileId: null }; +const catalog = [ + { provider: 'local', id: 'glm-flash', name: 'GLM Flash', authenticated: true, reasoning: true }, + { provider: 'remote', id: 'vision', name: 'Vision', authenticated: true, reasoning: true, imageInput: true }, + { provider: 'hidden', id: 'hidden', name: 'Hidden', authenticated: false, reasoning: true }, + ...Array.from({ length: 35 }, (_, i) => ({ provider: 'local', id: 'model-' + i, name: 'Model ' + String(i).padStart(2, '0'), authenticated: true, reasoning: false })) +].map(item => ({ contextWindow: 64000, maxOutputTokens: 8000, ...item })); +window.settings = installModelSettings(dialog, createModelCombobox, document, async (path, method, body) => { + if (method === 'PATCH') { window.saved = { ...body.settings, revision: body.revision + 1 }; return { settings: window.saved }; } + if (method !== 'GET') throw Error('Unexpected request: ' + path); + return { settings: window.saved, catalog, active: [] }; +}); +window.chosen = []; +window.items = [ + { value: '', label: '전역 모델 사용 · GLM Flash', inherited: true }, + { value: 'glm', label: 'GLM Flash', search: 'LOCAL glm-flash fast' }, + { value: 'vision', label: 'Vision', search: 'remote image' }, + ...Array.from({ length: 35 }, (_, i) => ({ value: 'extra-' + i, label: 'Extra ' + String(i).padStart(2, '0') })) +]; +window.combo = createModelCombobox(document, 'qa-model', 'QA 모델', value => window.chosen.push(value)); +window.combo.set(window.items, 'vision', 'Vision'); +document.getElementById('model-settings').before(window.combo.root); +dialog.showModal(); +await window.settings.open(); +window.ready = true; +`; +const build = await Bun.build({ + entrypoints: [ + "model-combobox-fixture", + `${client}styles.css`, + `${client}theme-boot.ts`, + ], + target: "browser", + define: { "process.env.NODE_ENV": '"production"' }, + plugins: [ + { + name: "model-combobox-fixture", + setup(builder) { + builder.onResolve({ filter: /^model-combobox-fixture$/ }, () => ({ + path: "fixture", + namespace: "fixture", + })); + builder.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ + loader: "js", + contents: fixture, + })); + }, + }, + ], +}); +assert.ok(build.success, build.logs.map(String).join("\n")); +const assets = new Map( + build.outputs.map((output) => [output.path.split("/").pop(), output]), +); +const html = (await Bun.file(`${client}index.html`).text()) + .replace('src="/app.js"', 'src="/model-combobox-fixture.js"') + .replace('src="/theme.js"', 'src="/theme-boot.js"'); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname.slice(1); + if (!path) + return new Response(html, { headers: { "Content-Type": "text/html" } }); + const asset = assets.get(path); + return asset ? new Response(asset) : new Response(null, { status: 404 }); + }, +}); +await mkdir(out, { recursive: true }); +const results = []; +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +async function expanded(input, value) { + await input + .page() + .waitForFunction( + ({ id, value }) => + document.getElementById(id).getAttribute("aria-expanded") === + String(value), + { id: await input.getAttribute("id"), value }, + ); +} +async function contained(page) { + await page.waitForFunction(() => { + const popup = document.querySelector(".model-combobox-popup"); + if (!popup) return false; + const r = popup.getBoundingClientRect(); + const clip = popup.closest("dialog").getBoundingClientRect(); + return ( + r.width > 0 && + r.height > 0 && + r.left >= Math.max(0, clip.left) && + r.right <= Math.min(innerWidth, clip.right) && + r.top >= Math.max(0, clip.top) && + r.bottom <= Math.min(innerHeight, clip.bottom) + ); + }); +} +async function check(name, run, viewport = { width: 1280, height: 577 }) { + if (process.env.LINA_QA_CASE && !name.includes(process.env.LINA_QA_CASE)) + return; + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error" && !message.text().includes("404")) + errors.push(message.text()); + }); + try { + await page.goto(server.url.href); + await page.waitForFunction(() => window.ready); + await run(page); + assert.deepEqual(errors, []); + results.push({ name, pass: true }); + } catch (error) { + await page.screenshot({ path: `${out}/${results.length}-failure.png` }); + results.push({ name, pass: false, error: String(error), errors }); + } finally { + await context.close(); + } +} +try { + await check( + "programmatic set(disabled) closes popup and preserves label and node", + async (page) => { + const input = page.getByRole("combobox", { + name: "QA 모델", + exact: true, + }); + await input.click(); + await expanded(input, true); + await page.evaluate(() => { + window.originalInput = window.combo.input; + window.combo.set(window.items, "gone", "연결 확인 필요", true); + }); + await expanded(input, false); + assert.equal(await input.isDisabled(), true); + assert.equal(await input.inputValue(), "연결 확인 필요"); + assert.equal(await input.getAttribute("title"), "연결 확인 필요"); + assert.equal( + await page.evaluate(() => window.originalInput === window.combo.input), + true, + ); + await page.evaluate(() => window.combo.disable(false)); + await input.click(); + await expanded(input, true); + await page.evaluate(() => window.combo.disable(true)); + await expanded(input, false); + assert.deepEqual(await page.evaluate(() => window.chosen), []); + }, + ); + await check( + "aliases, inherited ordering, empty status and keyboard selection", + async (page) => { + const input = page.getByRole("combobox", { + name: "QA 모델", + exact: true, + }); + await input.click(); + await expanded(input, true); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option") + .first() + .textContent(), + "전역 모델 사용 · GLM Flash", + ); + await input.fill("GLM"); + await page.waitForFunction( + () => document.querySelectorAll('[role="option"]').length === 2, + ); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option") + .first() + .textContent(), + "GLM Flash", + ); + await input.fill(" FAST local "); + await page.waitForFunction( + () => document.querySelectorAll('[role="option"]').length === 1, + ); + await input.press("ArrowDown"); + await input.press("Enter"); + await expanded(input, false); + assert.deepEqual(await page.evaluate(() => window.chosen), ["glm"]); + assert.equal(await input.inputValue(), "GLM Flash"); + await input.click(); + await expanded(input, true); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option", { name: "GLM Flash", exact: true }) + .getAttribute("aria-selected"), + "true", + ); + await input.fill("missing-model"); + await page.getByText("검색 결과가 없습니다.", { exact: true }).waitFor(); + assert.equal( + await page.locator('[role="listbox"] [role="status"]').count(), + 0, + ); + await input.press("Enter"); + assert.deepEqual(await page.evaluate(() => window.chosen), ["glm"]); + // Base UI dismisses on Enter without a highlighted option; reopen before Escape. + await input.click(); + await expanded(input, true); + await input.fill("missing-model"); + await page.getByText("검색 결과가 없습니다.", { exact: true }).waitFor(); + await input.press("Escape"); + assert.equal( + await page.locator("#settings-dialog").evaluate((node) => node.open), + true, + "first Escape only closes the popup", + ); + await expanded(input, false); + assert.equal(await input.inputValue(), "GLM Flash"); + assert.equal( + await page.locator("#settings-dialog").evaluate((node) => node.open), + true, + ); + await input.press("Escape"); + await page.waitForFunction( + () => !document.getElementById("settings-dialog").open, + ); + }, + ); + await check( + "keyboard opening, inherited selection and updates during search", + async (page) => { + const input = page.locator("#qa-model-input"); + await input.focus(); + await input.press("ArrowDown"); + await expanded(input, true); + await input.fill("extra"); + await input.press("ArrowUp"); + await input.press("Enter"); + await expanded(input, false); + assert.deepEqual(await page.evaluate(() => window.chosen), ["extra-34"]); + await input.click(); + await input.fill(""); + await page.getByRole("listbox").getByRole("option").first().click(); + assert.deepEqual(await page.evaluate(() => window.chosen), [ + "extra-34", + "", + ]); + await input.click(); + await input.fill("GLM"); + await page.evaluate(() => { + window.combo.input.setAttribute( + "aria-describedby", + "model-settings-status", + ); + window.combo.set(window.items, "vision", "Updated Vision"); + }); + assert.equal(await input.inputValue(), "GLM"); + assert.equal(await input.getAttribute("title"), "Updated Vision"); + await page.evaluate(() => window.combo.close()); + assert.equal(await input.inputValue(), "Updated Vision"); + assert.equal( + await input.getAttribute("aria-describedby"), + "model-settings-status", + ); + assert.deepEqual(await page.evaluate(() => window.chosen), [ + "extra-34", + "", + ]); + }, + ); + await check( + "pointer selection, tab/outside dismissal and explicit close", + async (page) => { + const input = page.getByRole("combobox", { + name: "QA 모델", + exact: true, + }); + await input.click(); + await expanded(input, true); + await page + .getByRole("listbox") + .getByRole("option", { name: "GLM Flash", exact: true }) + .click(); + await expanded(input, false); + assert.deepEqual(await page.evaluate(() => window.chosen), ["glm"]); + assert.equal( + await input.evaluate((node) => document.activeElement === node), + true, + ); + await input.click(); + await expanded(input, true); + await input.press("Tab"); + await expanded(input, false); + assert.equal( + await input.evaluate((node) => document.activeElement === node), + false, + ); + await input.click(); + await expanded(input, true); + await page.locator("#settings-heading").click(); + await expanded(input, false); + await input.click(); + await expanded(input, true); + await page.evaluate(() => window.combo.close()); + await expanded(input, false); + }, + ); + for (const viewport of [ + { width: 1280, height: 577 }, + { width: 390, height: 844 }, + ]) { + await check( + `dialog top layer and viewport collision ${viewport.width}`, + async (page) => { + const input = page.locator("#model-vision-input"); + await input.click(); + await expanded(input, true); + const list = page.getByRole("listbox"); + await list.waitFor(); + assert.equal( + await list.evaluate((node) => node.closest("dialog")?.id), + "settings-dialog", + ); + assert.equal( + await list.evaluate( + (node) => node.closest(".settings-content") === null, + ), + true, + "popup escapes the settings scrollport", + ); + const option = page + .getByRole("listbox") + .getByRole("option", { name: "Vision · remote", exact: true }); + await option.scrollIntoViewIfNeeded(); + assert.equal( + await option.evaluate((node) => { + const r = node.getBoundingClientRect(); + const hit = document.elementFromPoint( + r.x + r.width / 2, + r.y + r.height / 2, + ); + return node === hit || node.contains(hit); + }), + true, + "option is hit-testable in native dialog top layer", + ); + await option.click(); + await expanded(input, false); + const dense = page.locator("#model-recall-input"); + await dense.click(); + await expanded(dense, true); + await contained(page); + await page + .getByRole("listbox") + .getByRole("option", { name: "Model 34 · local", exact: true }) + .scrollIntoViewIfNeeded(); + await page + .getByRole("listbox") + .getByRole("option", { name: "Model 34 · local", exact: true }) + .click(); + await expanded(dense, false); + await dense.click(); + await expanded(dense, true); + await contained(page); + await page + .getByRole("listbox") + .getByRole("option", { name: "Model 34 · local", exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ path: `${out}/dialog-${viewport.width}.png` }); + }, + viewport, + ); + } + await check( + "open popup follows resize and settings scrolling", + async (page) => { + const input = page.locator("#model-recall-input"); + await input.click(); + await expanded(input, true); + await contained(page); + await page.setViewportSize({ width: 1024, height: 650 }); + await contained(page); + await page.locator(".settings-content").evaluate((node) => { + node.scrollTop -= 60; + }); + await page.waitForFunction(() => { + const input = document + .getElementById("model-recall-input") + .getBoundingClientRect(); + const positioner = document.querySelector(".model-combobox-positioner"); + const popup = positioner.getBoundingClientRect(); + const gap = + positioner.dataset.side === "top" + ? input.top - popup.bottom + : popup.top - input.bottom; + return Math.abs(gap - 5) <= 1 && Math.abs(input.left - popup.left) <= 1; + }); + await contained(page); + await page.screenshot({ path: `${out}/resized.png` }); + }, + ); + await check( + "bottom-edge anchor flips above within the native dialog", + async (page) => { + await page.evaluate(() => { + const dialog = document.getElementById("settings-dialog"); + dialog.append(window.combo.root); + Object.assign(window.combo.root.style, { + position: "absolute", + bottom: "16px", + left: "24px", + width: "260px", + }); + }); + const input = page.locator("#qa-model-input"); + await input.click(); + await expanded(input, true); + await page.waitForFunction( + () => + document.querySelector(".model-combobox-positioner")?.dataset.side === + "top", + ); + await contained(page); + const boxes = await page.evaluate(() => ({ + inputTop: document + .getElementById("qa-model-input") + .getBoundingClientRect().top, + popupBottom: document + .querySelector(".model-combobox-popup") + .getBoundingClientRect().bottom, + })); + assert.ok(boxes.popupBottom < boxes.inputTop); + await page.screenshot({ path: `${out}/flipped.png` }); + }, + ); + await check( + "real model settings selection saves correct roles and filters providers", + async (page) => { + const input = page.locator("#model-default-input"); + await input.click(); + await input.fill("glm-flash"); + await page + .getByRole("listbox") + .getByRole("option", { name: "GLM Flash · local", exact: true }) + .click(); + const summary = page.locator("#model-summary-input"); + await summary.click(); + await summary.fill("GLM"); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option") + .first() + .textContent(), + "GLM Flash · local", + ); + await page + .getByRole("listbox") + .getByRole("option", { name: "GLM Flash · local", exact: true }) + .click(); + await page.locator("#model-save").click(); + await page.waitForFunction(() => window.saved.revision === 1); + assert.equal( + await page.evaluate( + () => + window.saved.profiles.find( + (p) => p.id === window.saved.roles.summary, + ).model, + ), + "glm-flash", + ); + await input.click(); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option", { name: "Hidden · hidden", exact: true }) + .count(), + 0, + ); + await input.press("Escape"); + await page.locator("#model-vision-input").click(); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option", { name: "GLM Flash · local", exact: true }) + .count(), + 0, + ); + assert.equal( + await page + .getByRole("listbox") + .getByRole("option", { name: "Vision · remote", exact: true }) + .count(), + 1, + ); + }, + ); +} finally { + await browser.close(); + await server.stop(true); + await writeFile(`${out}/results.json`, JSON.stringify(results, null, 2)); +} +console.log(JSON.stringify(results, null, 2)); +if (results.some((result) => !result.pass)) process.exitCode = 1; diff --git a/scripts/qa/ui-navigation-regressions.mjs b/scripts/qa/ui-navigation-regressions.mjs new file mode 100644 index 0000000..cf78580 --- /dev/null +++ b/scripts/qa/ui-navigation-regressions.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; + +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const base = process.env.LINA_QA_URL ?? "http://127.0.0.1:18140"; +const out = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-ui-desktop-evidence"; +await mkdir(out, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const results = []; +async function check(name, run, blockedStorage = false) { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + }); + if (blockedStorage) + await context.addInitScript(() => { + Storage.prototype.setItem = () => { + throw new DOMException("full", "QuotaExceededError"); + }; + }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + try { + await run(page); + assert.deepEqual(errors, []); + results.push({ name, pass: true }); + } catch (error) { + await page.screenshot({ + path: `${out}/navigation-${results.length}-failure.png`, + }); + results.push({ name, pass: false, error: String(error), errors }); + } finally { + await context.close(); + } +} +await check("browser Back closes mobile settings", async (page) => { + await page.goto(`${base}/?agent=lina&screen=agents`); + await page.locator("#other-agents button").first().waitFor(); + await page.locator("#mobile-settings").click(); + await page.locator("#settings-dialog").waitFor({ state: "visible" }); + await page.goBack(); + assert.equal(new URL(page.url()).searchParams.get("screen"), "agents"); + assert.equal(await page.locator("#settings-dialog").isVisible(), false); +}); +await check("new navigation cancels late intro selection", async (page) => { + await page.goto(`${base}/?agent=lina&screen=agents`); + await page + .locator('#other-agents [data-agent-id="kai"] .agent-link') + .waitFor(); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/api/agents/kai/intro", async (route) => { + await gate; + await route.fulfill({ + contentType: "application/json", + body: '{"room":null}', + }); + }); + const sent = page.waitForRequest((request) => + request.url().endsWith("/api/agents/kai/intro"), + ); + await page.locator('#other-agents [data-agent-id="kai"] .agent-link').click(); + await sent; + await page.locator('.mobile-navigation [data-screen="tasks"]').click(); + const response = page.waitForResponse((response) => + response.url().endsWith("/api/agents/kai/intro"), + ); + release(); + await (await response).finished(); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(resolve)), + ); + assert.equal(new URL(page.url()).searchParams.get("screen"), "tasks"); + assert.equal(new URL(page.url()).searchParams.get("agent"), "lina"); +}); +await check( + "mobile conversation exposes search without a hardware keyboard", + async (page) => { + await page.goto(`${base}/?agent=lina`); + await page.locator("#messages .message").first().waitFor(); + const buttons = page.getByRole("button", { + name: "대화 검색", + exact: true, + }); + assert.equal(await buttons.count(), 1); + await buttons.click(); + await page.locator("#search-dialog").waitFor({ state: "visible" }); + }, +); +await check( + "mobile reading anchor survives blocked storage and disconnected sockets", + async (page) => { + await page.goto(`${base}/?agent=lina`); + await page.locator("#messages .message").first().waitFor(); + await page.locator("#conversation-scroll").evaluate((node) => { + node.scrollTop = 380; + }); + const top = await page + .locator("#conversation-scroll") + .evaluate((node) => node.scrollTop); + await page.locator("#open-sidebar").click(); + await page + .locator('#other-agents [data-agent-id="kai"] .agent-link') + .click(); + await page.waitForFunction(() => + document.querySelector("#messages").textContent.includes("Kai의 대화"), + ); + await page.locator("#open-sidebar").click(); + await page.evaluate(() => { + window.originalSocket = WebSocket; + window.WebSocket = class extends window.originalSocket { + constructor(url) { + super(url); + this.close(); + } + }; + }); + await page + .locator('#other-agents [data-agent-id="lina"] .agent-link') + .click(); + await page.waitForFunction(() => + document.querySelector("#messages").textContent.includes("Lina의 대화"), + ); + await page.waitForFunction( + (top) => + Math.abs( + document.querySelector("#conversation-scroll").scrollTop - top, + ) < 3, + top, + ); + const restored = await page + .locator("#conversation-scroll") + .evaluate((node) => node.scrollTop); + assert.ok(Math.abs(restored - top) < 3, `${top} → ${restored}`); + assert.equal(await page.locator("#connection-status").isVisible(), true); + await page.evaluate(() => { + window.WebSocket = window.originalSocket; + }); + await page.locator("#reconnect").click(); + await page.waitForFunction( + () => document.querySelector("#connection-status").hidden, + ); + assert.ok( + Math.abs( + (await page + .locator("#conversation-scroll") + .evaluate((node) => node.scrollTop)) - top, + ) < 3, + ); + }, + true, +); +await browser.close(); +await writeFile( + `${out}/navigation-results.json`, + JSON.stringify(results, null, 2), +); +console.log(JSON.stringify(results)); +if (results.some((result) => !result.pass)) process.exitCode = 1; diff --git a/scripts/qa/ui-settings-islands.mjs b/scripts/qa/ui-settings-islands.mjs new file mode 100644 index 0000000..2a48863 --- /dev/null +++ b/scripts/qa/ui-settings-islands.mjs @@ -0,0 +1,394 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +// Run with Bun. Uses the real settings markup/styles and adapters without APIs. +const client = fileURLToPath( + new URL("../../packages/lina-ui/client/", import.meta.url), +); +const out = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-settings-islands"; +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const build = await Bun.build({ + entrypoints: [ + "settings-islands-fixture", + `${client}styles.css`, + `${client}theme-boot.ts`, + ], + target: "browser", + define: { "process.env.NODE_ENV": '"production"' }, + minify: true, + plugins: [ + { + name: "settings-islands-fixture", + setup(builder) { + builder.onResolve({ filter: /^settings-islands-fixture$/ }, () => ({ + path: "fixture", + namespace: "fixture", + })); + builder.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ + loader: "js", + contents: ` +import { installSettingsTabs } from ${JSON.stringify(`${client}settings-tabs.ts`)}; +import { installThemeControls } from ${JSON.stringify(`${client}theme-controls.ts`)}; +const dialog = document.getElementById("settings-dialog"); +const nav = dialog.querySelector(".settings-navigation"); +nav.replaceChildren(); +window.panelNodes = [...dialog.querySelectorAll('[role="tabpanel"]')]; +window.changes = []; +window.tabs = installSettingsTabs(dialog, tab => window.changes.push(tab)); +const original = dialog.querySelector(".theme-switch"); +original.replaceChildren(); +const duplicate = original.cloneNode(true); +duplicate.id = "intro-theme"; +duplicate.removeAttribute("aria-labelledby"); +duplicate.setAttribute("aria-label", "소개 화면 모드"); +dialog.querySelector('#settings-panel-general').append(duplicate); +window.choiceHosts = [original, duplicate]; +window.failures = 0; +installThemeControls(() => { window.failures++; }); +dialog.showModal(); +window.ready = true; +`, + })); + }, + }, + ], +}); +assert.ok(build.success, build.logs.map(String).join("\n")); +const assets = new Map( + build.outputs.map((output) => [output.path.split("/").pop(), output]), +); +const html = (await Bun.file(`${client}index.html`).text()) + .replace('src="/app.js"', 'src="/settings-islands-fixture.js"') + .replace('src="/theme.js"', 'src="/theme-boot.js"'); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname.slice(1); + if (!path) + return new Response(html, { headers: { "Content-Type": "text/html" } }); + const asset = assets.get(path); + return asset ? new Response(asset) : new Response(null, { status: 404 }); + }, +}); +const fixtureUrl = server.url.href; +console.log(`Settings islands fixture: ${fixtureUrl}`); +await mkdir(out, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const results = []; +async function check(name, run, options = {}) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + colorScheme: "light", + }); + if (options.storage) + await context.addInitScript((mode) => { + if (mode === "get") + Object.defineProperty(window, "localStorage", { + get() { + throw new DOMException("blocked", "SecurityError"); + }, + }); + else + Storage.prototype.setItem = () => { + throw new DOMException("full", "QuotaExceededError"); + }; + }, options.storage); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error" && !message.text().includes("404")) + errors.push(message.text()); + }); + try { + await page.goto(server.url.href); + await page.waitForFunction(() => window.ready); + await run(page); + assert.deepEqual(errors, []); + results.push({ name, pass: true }); + } catch (error) { + await page.screenshot({ path: `${out}/${results.length}-failure.png` }); + results.push({ name, pass: false, error: String(error), errors }); + } finally { + await context.close(); + } +} +async function selected(page, tab) { + await page.waitForFunction( + (id) => + window.tabs.current === id && + document + .getElementById(`settings-tab-${id}`) + .getAttribute("aria-selected") === "true", + tab, + ); + assert.equal( + await page.locator('[role="tabpanel"]:visible').getAttribute("id"), + `settings-panel-${tab}`, + ); + const orientation = await page + .getByRole("tablist") + .getAttribute("aria-orientation"); + const boxes = await page.getByRole("tab").evaluateAll((tabs) => + tabs.map((tab) => { + const { x, y, width, height } = tab.getBoundingClientRect(); + return { x, y, width, height }; + }), + ); + if (orientation === "vertical") { + assert.equal(boxes[0].x, boxes[1].x, "vertical tab alignment"); + const listWidth = await page + .getByRole("tablist") + .evaluate((list) => list.getBoundingClientRect().width); + assert.ok( + boxes.every((box) => box.width === listWidth), + "desktop tabs stretch to the full list width", + ); + assert.ok( + boxes[1].y >= boxes[0].y + boxes[0].height, + "vertical tabs do not overlap", + ); + } else { + assert.equal(boxes[0].y, boxes[1].y, "horizontal tab alignment"); + assert.ok( + boxes[1].x >= boxes[0].x + boxes[0].width, + "horizontal tabs do not overlap", + ); + } +} +await check( + "desktop vertical keys, panel adapter, Home/End and select focus", + async (page) => { + const general = page.getByRole("tab", { name: "일반", exact: true }); + await general.focus(); + await general.press("ArrowRight"); + assert.equal( + await page.evaluate(() => window.tabs.current), + "general", + "vertical tabs ignore ArrowRight", + ); + await general.press("ArrowDown"); + await selected(page, "connections"); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "settings-tab-connections", + ); + assert.deepEqual(await page.evaluate(() => window.changes), [ + "connections", + ]); + await page.keyboard.press("End"); + await selected(page, "models"); + await page.keyboard.press("ArrowDown"); + await selected(page, "general"); + await page.keyboard.press("End"); + await page.keyboard.press("Home"); + await selected(page, "general"); + await page.evaluate(() => { + window.tabs.select("models", true); + const content = document.querySelector(".settings-content"); + const tall = document.createElement("div"); + tall.style.height = "2000px"; + document.getElementById("settings-panel-models").append(tall); + content.scrollTop = 120; + window.tabs.select("models"); + window.sameScroll = content.scrollTop; + window.tabs.select("connections", true); + }); + assert.equal(await page.evaluate(() => window.sameScroll), 120); + assert.equal( + await page + .locator(".settings-content") + .evaluate((node) => node.scrollTop), + 0, + ); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "settings-tab-connections", + ); + assert.equal( + await page.evaluate(() => + window.panelNodes.every( + (node) => node === document.getElementById(node.id), + ), + ), + true, + ); + assert.equal(await page.getByRole("tablist").count(), 1); + for (const tab of ["general", "connections", "models"]) { + assert.equal( + await page + .locator(`#settings-tab-${tab}`) + .getAttribute("aria-controls"), + `settings-panel-${tab}`, + ); + assert.equal( + await page + .locator(`#settings-panel-${tab}`) + .getAttribute("aria-labelledby"), + `settings-tab-${tab}`, + ); + } + await page.screenshot({ path: `${out}/desktop.png` }); + }, +); +await check( + "mobile horizontal keys and live orientation resize", + async (page) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForFunction( + () => + document + .querySelector('[role="tablist"]') + .getAttribute("aria-orientation") === "horizontal", + ); + const general = page.getByRole("tab", { name: "일반", exact: true }); + await general.focus(); + await general.press("ArrowDown"); + assert.equal( + await page.evaluate(() => window.tabs.current), + "general", + "horizontal tabs ignore ArrowDown", + ); + await general.press("ArrowRight"); + await selected(page, "connections"); + await page.keyboard.press("ArrowLeft"); + await selected(page, "general"); + await page.setViewportSize({ width: 1024, height: 768 }); + await page.waitForFunction( + () => + document + .querySelector('[role="tablist"]') + .getAttribute("aria-orientation") === "vertical", + ); + await page.keyboard.press("ArrowDown"); + await selected(page, "connections"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForFunction( + () => + document + .querySelector('[role="tablist"]') + .getAttribute("aria-orientation") === "horizontal", + ); + await page.screenshot({ path: `${out}/mobile.png` }); + }, +); +async function theme(page, value, resolved) { + await page.waitForFunction( + ({ value, resolved }) => + document.documentElement.dataset.theme === resolved && + document.querySelectorAll("[data-theme-choice]").length === 6 && + [...document.querySelectorAll("[data-theme-choice]")].every( + (button) => + button.getAttribute("aria-pressed") === + String(button.dataset.themeChoice === value), + ), + { value, resolved }, + ); + assert.equal( + await page.evaluate( + () => + document.querySelector('meta[name="theme-color"]').content === + getComputedStyle(document.documentElement) + .getPropertyValue("--surface-main") + .trim(), + ), + true, + ); + assert.equal( + await page.evaluate( + () => getComputedStyle(document.documentElement).colorScheme, + ), + resolved, + ); +} +await check( + "empty theme hosts preserve labels, attributes, persistence and OS changes", + async (page) => { + await theme(page, "dark", "dark"); + assert.deepEqual( + await page.locator("#intro-theme button").allTextContents(), + ["다크", "라이트", "시스템"], + ); + assert.equal( + await page.locator("#intro-theme").getAttribute("aria-label"), + "소개 화면 모드", + ); + assert.equal( + await page + .locator(".theme-switch") + .first() + .getAttribute("aria-labelledby"), + "theme-label", + ); + await page.locator('#intro-theme [data-theme-choice="light"]').click(); + await theme(page, "light", "light"); + assert.equal( + await page.evaluate(() => localStorage.getItem("lina.theme.v1")), + "light", + ); + await page.reload(); + await page.waitForFunction(() => window.ready); + await theme(page, "light", "light"); + await page.locator('#intro-theme [data-theme-choice="system"]').click(); + await theme(page, "system", "light"); + await page.emulateMedia({ colorScheme: "dark" }); + await theme(page, "system", "dark"); + assert.equal( + await page.evaluate(() => localStorage.getItem("lina.theme.v1")), + "system", + ); + await page.locator('[data-theme-choice="light"]').first().click(); + await theme(page, "light", "light"); + await page.emulateMedia({ colorScheme: "light" }); + await page.emulateMedia({ colorScheme: "dark" }); + await theme(page, "light", "light"); + assert.equal( + await page.evaluate(() => + window.choiceHosts.every( + (host) => host.isConnected && host.children.length === 3, + ), + ), + true, + ); + await page.screenshot({ path: `${out}/theme-light.png` }); + }, +); +for (const storage of ["get", "set"]) + await check( + `theme remains usable when storage ${storage} throws`, + async (page) => { + await page.locator('[data-theme-choice="system"]').first().click(); + await theme(page, "system", "light"); + assert.equal(await page.evaluate(() => window.failures), 1); + await page.emulateMedia({ colorScheme: "dark" }); + await theme(page, "system", "dark"); + }, + { storage }, + ); +await browser.close(); +await server.stop(true); +await writeFile( + `${out}/teardown.json`, + JSON.stringify( + { + fixtureUrl, + serverStopped: true, + browserConnected: browser.isConnected(), + }, + null, + 2, + ), +); +await writeFile(`${out}/results.json`, JSON.stringify(results, null, 2)); +console.log(JSON.stringify(results, null, 2)); +if (results.some((result) => !result.pass)) process.exitCode = 1; diff --git a/scripts/qa/ui-task-layout.mjs b/scripts/qa/ui-task-layout.mjs new file mode 100644 index 0000000..cc74dc7 --- /dev/null +++ b/scripts/qa/ui-task-layout.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; + +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const output = process.env.LINA_QA_OUTPUT ?? "/tmp/lina-task-layout-ux"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +page.setDefaultTimeout(5000); +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +const layout = (value) => + page.waitForFunction( + (value) => document.documentElement.dataset.layout === value, + value, + { timeout: 5000 }, + ); +try { + await page.goto(process.env.LINA_QA_URL ?? "http://127.0.0.1:18160"); + await page.locator("#other-agents .agent-row").first().waitFor(); + assert.equal( + await page.locator(".list-switch .icon-button").count(), + 0, + "list tabs must not contain an ambiguous layout icon", + ); + assert.equal(await page.locator("#collapse-sidebar").count(), 1); + assert.equal( + await page.locator("#toggle-task-column").isVisible(), + false, + "layout action belongs only to tasks", + ); + await page.screenshot({ path: `${output}/agents.png` }); + await page.locator("#message").fill("목록 배치를 바꿔도 보존할 초안"); + await page.locator('.list-switch [data-screen="tasks"]').click(); + const toggle = page.locator("#task-pane #toggle-task-column"); + await toggle.waitFor(); + assert.equal(await toggle.innerText(), "나란히 보기"); + await page.locator("#task-list .task-link").first().waitFor(); + await page.locator("#task-scope").selectOption("all"); + await page.evaluate(() => { + window.retainedTaskList = document.querySelector("#task-list"); + }); + await page.screenshot({ path: `${output}/tasks.png` }); + await toggle.click(); + await layout("dual"); + assert.equal(await toggle.innerText(), "나란히 보기 종료"); + assert.equal(await toggle.getAttribute("aria-pressed"), "true"); + assert.equal(await page.locator("#agent-pane").isVisible(), true); + assert.equal(await page.locator("#task-column #task-pane").isVisible(), true); + assert.equal( + await page.locator("#close-task-column").count(), + 0, + "one control owns the view choice", + ); + await page.screenshot({ path: `${output}/together.png` }); + await toggle.click(); + await layout("single"); + assert.equal(await page.locator("#sidebar #task-pane").isVisible(), true); + assert.equal(await page.locator("#task-scope").inputValue(), "all"); + assert.equal( + await page.evaluate( + () => window.retainedTaskList === document.querySelector("#task-list"), + ), + true, + ); + await page.waitForFunction( + () => document.activeElement?.id === "toggle-task-column", + ); + assert.equal( + await page.locator("#message").inputValue(), + "목록 배치를 바꿔도 보존할 초안", + ); + await toggle.click(); + await layout("dual"); + await page.reload(); + await layout("dual"); + await toggle.waitFor(); + assert.equal(await toggle.innerText(), "나란히 보기 종료"); + await page.locator('.list-switch [data-screen="agents"]').click(); + await toggle.click(); + await layout("single"); + await page.waitForFunction( + () => + document.activeElement === + document.querySelector('.list-switch [data-screen="agents"]'), + ); + await page.locator('.list-switch [data-screen="tasks"]').click(); + // 1440px with a wide sidebar cannot fit both lists and the reading column. + await page.locator("#sidebar-resizer").focus(); + await page.keyboard.press("End"); + await page.waitForFunction( + () => + document.querySelector("#sidebar").getBoundingClientRect().width >= 419, + ); + assert.equal( + await toggle.isVisible(), + false, + "hide an unavailable split action, not an ineffective toggle", + ); + await page.setViewportSize({ width: 1600, height: 900 }); + await toggle.waitFor(); + await toggle.click(); + await layout("dual"); + await page.setViewportSize({ width: 1280, height: 900 }); + await layout("single"); + assert.equal(await toggle.isVisible(), false); + await page.setViewportSize({ width: 1600, height: 900 }); + await layout("dual"); + assert.equal(await toggle.innerText(), "나란히 보기 종료"); + await page.setViewportSize({ width: 390, height: 844 }); + await layout("mobile"); + assert.equal(await toggle.isVisible(), false); + assert.equal(await page.locator("#task-pane").isVisible(), true); + assert.equal(await page.locator("#collapse-sidebar").isVisible(), false); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ); + await page.screenshot({ path: `${output}/mobile-tasks.png` }); + assert.deepEqual(errors, []); + console.log( + "PASS: contextual action, one toggle, active label, focus, retained list/filter/draft, reload, actual available width and mobile", + ); +} finally { + await browser.close(); +} diff --git a/scripts/qa/ui-task-restart.mjs b/scripts/qa/ui-task-restart.mjs new file mode 100644 index 0000000..e6bffe9 --- /dev/null +++ b/scripts/qa/ui-task-restart.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { chromium } = await import( + process.env.LINA_QA_PLAYWRIGHT_MODULE ?? "playwright" +); +const profile = await mkdtemp(join(tmpdir(), "lina-task-browser-")); +const options = { + headless: true, + viewport: { width: 1440, height: 900 }, + ...(process.env.LINA_QA_CHROMIUM + ? { executablePath: process.env.LINA_QA_CHROMIUM } + : {}), +}; +const url = + (process.env.LINA_QA_URL ?? "http://127.0.0.1:18140") + + "/?agent=lina&task=task-1"; +let context; +try { + context = await chromium.launchPersistentContext(profile, options); + let page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => + document.querySelector("#task-heading").textContent.includes("로그인 오류"), + ); + await page.locator("#task-input").fill("브라우저를 닫아도 남는 작업 입력"); + await page.locator("#task-owner").selectOption("kai"); + await page.locator("#task-dialog [data-close]").first().click(); + await page.locator("#task-scope").selectOption("all"); + await context.close(); + context = undefined; + context = await chromium.launchPersistentContext(profile, options); + page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => + document.querySelector("#task-heading").textContent.includes("로그인 오류"), + ); + assert.equal( + await page.locator("#task-input").inputValue(), + "브라우저를 닫아도 남는 작업 입력", + ); + assert.equal(await page.locator("#task-owner").inputValue(), "kai"); + await page.locator("#task-dialog [data-close]").first().click(); + assert.equal(await page.locator("#task-scope").inputValue(), "all"); + const result = { + pass: true, + independentBrowserProcesses: 2, + taskInputRestored: true, + ownerDraftRestored: true, + allOwnerFilterRestored: true, + serverMutation: false, + }; + await writeFile( + join( + process.env.LINA_QA_OUTPUT ?? "/tmp/lina-ui-desktop-evidence", + "task-restart.json", + ), + JSON.stringify(result, null, 2), + ); + console.log(JSON.stringify(result)); +} finally { + await context?.close(); + await rm(profile, { recursive: true, force: true }); +} diff --git a/tsconfig.json b/tsconfig.json index 41e3eaf..4165228 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ ], "exclude": [ "node_modules", - "dist" + "dist", + "packages/lina-desktop" ] }