diff --git a/.gitignore b/.gitignore index 4ca52a72b..8c0b6f757 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist *.tgz .build apps/electron/release +apps/electron/electron-builder.generated.yml apps/electron/resources/session-mcp-server/ apps/electron/resources/pi-agent-server/ diff --git a/apps/electron/electron-builder.modelstudio.yml b/apps/electron/electron-builder.modelstudio.yml deleted file mode 100644 index c34925f98..000000000 --- a/apps/electron/electron-builder.modelstudio.yml +++ /dev/null @@ -1,166 +0,0 @@ -appId: com.alibaba.modelstudio-desktop -productName: ModelStudio Desktop -copyright: Copyright © 2026 Alibaba Group. - -electronVersion: "39.2.7" - -# Hook to compile macOS 26+ Liquid Glass icon after packaging -afterPack: scripts/afterPack.cjs - -directories: - output: release - buildResources: resources - -files: - - dist/**/* - - "!dist/renderer/src/**" - - "!**/*.map" - - package.json - # Include bundled MCP servers (bridge + session) for agent sessions - - resources/bridge-mcp-server/**/* - - resources/session-mcp-server/**/* - # Note: Bundled assets (docs, themes, permissions, tool-icons) are in resources/ - # and copied to dist/resources/ by build:copy. They're included via dist/**/* above. - - packages/shared/src/interceptor-common.ts - - packages/shared/src/feature-flags.ts - - packages/shared/src/interceptor-request-utils.ts - # Include CLI tool Python scripts (platform-independent) - - resources/scripts/**/* - # Include CLI tool shell wrappers (platform-independent, both Unix and Windows) - - resources/bin/markitdown - - resources/bin/markitdown.cmd - - resources/bin/pdf-tool - - resources/bin/pdf-tool.cmd - - resources/bin/xlsx-tool - - resources/bin/xlsx-tool.cmd - - resources/bin/doc-diff - - resources/bin/doc-diff.cmd - - resources/bin/img-tool - - resources/bin/img-tool.cmd - - resources/bin/docx-tool - - resources/bin/docx-tool.cmd - - resources/bin/pptx-tool - - resources/bin/pptx-tool.cmd - - resources/bin/ical-tool - - resources/bin/ical-tool.cmd - # Include bundled uv binary (platform-specific, only the relevant one ships per build) - - resources/bin/darwin-arm64/**/* - - resources/bin/darwin-x64/**/* - - resources/bin/win32-x64/**/* - - resources/bin/linux-x64/**/* - # Include bundled Bun runtime (platform-specific, set by build script) - - vendor/bun/**/* - # Include bundled Codex binary (platform-specific, downloaded by build script) - - vendor/codex/**/* - # Include bundled Qwen Code CLI package (downloaded by build script) - - vendor/qwen-code/**/* - # Exclude everything from node_modules. - - "!node_modules/**/*" - -extraMetadata: - main: dist/main.cjs - -# Auto-update is disabled until we have an owned update server. - -# Disable ASAR to avoid decompression overhead and click delays -asar: false - -mac: - category: public.app-category.productivity - icon: resources/brands/modelstudio/icon.icns - # macOS 26+ Liquid Glass: Tell macOS to look for icon in Assets.car - # The value must match --app-icon used in actool (see afterPack.js) - extendInfo: - CFBundleIconName: AppIcon - target: - - target: dmg - arch: - - arm64 - - x64 - - target: zip - arch: - - arm64 - - x64 - hardenedRuntime: true - gatekeeperAssess: false - entitlements: build/entitlements.mac.plist - entitlementsInherit: build/entitlements.mac.plist - extraResources: - # WhatsApp worker subprocess (self-contained; Baileys bundled in). - - from: ../../packages/messaging-whatsapp-worker/dist/worker.cjs - to: messaging-whatsapp-worker/worker.cjs - # Exclude binaries for other platforms - files: - - "!**/vendor/codex/linux-*/**" - - "!**/vendor/codex/win32-*/**" - - "!**/resources/bin/win32-*/**" - - "!**/resources/bin/linux-*/**" - # Use predictable naming for macOS packages - artifactName: "ModelStudio-Desktop-${arch}.${ext}" - -dmg: - artifactName: "ModelStudio-Desktop-${arch}.dmg" - # Custom background (reuse existing) - background: resources/dmg-background.tiff - # Use modelstudio icon as the mounted volume icon in Finder - icon: resources/brands/modelstudio/icon.icns - iconSize: 80 - title: "ModelStudio Desktop" - contents: - - x: 130 - y: 200 - - x: 410 - y: 200 - type: link - path: /Applications - window: - width: 540 - height: 380 - -win: - icon: resources/brands/modelstudio/icon.png - target: - - target: nsis - arch: - - x64 - artifactName: "ModelStudio-Desktop-${arch}.${ext}" - files: - - "!**/vendor/codex/darwin-*/**" - - "!**/vendor/codex/linux-*/**" - - "!**/resources/bin/darwin-*/**" - - "!**/resources/bin/linux-*/**" - - "!vendor/bun/**/*" - - "!vendor/codex/**/*" - - "!**/resources/bin/win32-x64/**" - extraResources: - - from: vendor/bun/bun.exe - to: vendor/bun/bun.exe - - from: vendor/codex/win32-x64 - to: app/vendor/codex/win32-x64 - - from: resources/bin/win32-x64 - to: app/resources/bin/win32-x64 - - from: ../../packages/messaging-whatsapp-worker/dist/worker.cjs - to: messaging-whatsapp-worker/worker.cjs - -nsis: - oneClick: true - perMachine: false - deleteAppDataOnUninstall: true - -linux: - icon: resources/brands/modelstudio/icon.png - category: Utility - maintainer: "Alibaba Group" - target: - - target: AppImage - arch: - - x64 - artifactName: "ModelStudio-Desktop-${arch}.${ext}" - extraResources: - - from: ../../packages/messaging-whatsapp-worker/dist/worker.cjs - to: messaging-whatsapp-worker/worker.cjs - files: - - "!**/vendor/codex/darwin-*/**" - - "!**/vendor/codex/win32-*/**" - - "!**/resources/bin/darwin-*/**" - - "!**/resources/bin/win32-*/**" diff --git a/apps/electron/electron-builder.yml b/apps/electron/electron-builder.yml index 18c2a9b50..cd92a7168 100644 --- a/apps/electron/electron-builder.yml +++ b/apps/electron/electron-builder.yml @@ -67,7 +67,7 @@ asar: false mac: category: public.app-category.productivity - icon: resources/icon.icns + icon: resources/brands/qwen-code/icon.icns # macOS 26+ Liquid Glass: Tell macOS to look for icon in Assets.car # The value must match --app-icon used in actool (see afterPack.js) extendInfo: @@ -108,7 +108,7 @@ dmg: # Custom background (multi-resolution TIFF with 1x+2x for retina support) background: resources/dmg-background.tiff # Use app icon as the mounted volume icon in Finder - icon: resources/icon.icns + icon: resources/brands/qwen-code/icon.icns iconSize: 80 title: "Qwen Code Desktop" contents: @@ -123,7 +123,7 @@ dmg: height: 380 win: - icon: resources/icon.ico + icon: resources/brands/qwen-code/icon.ico target: - target: nsis arch: @@ -164,7 +164,7 @@ nsis: deleteAppDataOnUninstall: true linux: - icon: resources/icon.png + icon: resources/brands/qwen-code/icon.png category: Utility maintainer: "Alibaba Group" target: diff --git a/apps/electron/resources/brands/openwork/dock.png b/apps/electron/resources/brands/openwork/dock.png new file mode 100644 index 000000000..787bb52b0 Binary files /dev/null and b/apps/electron/resources/brands/openwork/dock.png differ diff --git a/apps/electron/resources/brands/modelstudio/icon.icns b/apps/electron/resources/brands/openwork/icon.icns similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.icns rename to apps/electron/resources/brands/openwork/icon.icns diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_128x128.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_128x128.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_128x128.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_128x128.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_128x128@2x.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_128x128@2x.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_128x128@2x.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_128x128@2x.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_16x16.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_16x16.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_16x16.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_16x16.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_16x16@2x.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_16x16@2x.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_16x16@2x.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_16x16@2x.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_256x256.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_256x256.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_256x256.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_256x256.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_256x256@2x.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_256x256@2x.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_256x256@2x.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_256x256@2x.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_32x32.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_32x32.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_32x32.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_32x32.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_32x32@2x.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_32x32@2x.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_32x32@2x.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_32x32@2x.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_512x512.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_512x512.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_512x512.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_512x512.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_512x512@2x.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_512x512@2x.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_512x512@2x.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_512x512@2x.png diff --git a/apps/electron/resources/brands/modelstudio/icon.iconset/icon_64x64.png b/apps/electron/resources/brands/openwork/icon.iconset/icon_64x64.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.iconset/icon_64x64.png rename to apps/electron/resources/brands/openwork/icon.iconset/icon_64x64.png diff --git a/apps/electron/resources/brands/modelstudio/icon.png b/apps/electron/resources/brands/openwork/icon.png similarity index 100% rename from apps/electron/resources/brands/modelstudio/icon.png rename to apps/electron/resources/brands/openwork/icon.png diff --git a/apps/electron/src/renderer/assets/modelstudio-icon.png b/apps/electron/resources/brands/openwork/symbol.png similarity index 100% rename from apps/electron/src/renderer/assets/modelstudio-icon.png rename to apps/electron/resources/brands/openwork/symbol.png diff --git a/apps/electron/resources/brands/qwen-code/dock.png b/apps/electron/resources/brands/qwen-code/dock.png new file mode 100644 index 000000000..45724f398 Binary files /dev/null and b/apps/electron/resources/brands/qwen-code/dock.png differ diff --git a/apps/electron/resources/brands/qwen-code/icon.icns b/apps/electron/resources/brands/qwen-code/icon.icns new file mode 100644 index 000000000..4ef481b36 Binary files /dev/null and b/apps/electron/resources/brands/qwen-code/icon.icns differ diff --git a/apps/electron/resources/icon.ico b/apps/electron/resources/brands/qwen-code/icon.ico similarity index 100% rename from apps/electron/resources/icon.ico rename to apps/electron/resources/brands/qwen-code/icon.ico diff --git a/apps/electron/resources/icon.icon/Assets/icon.svg b/apps/electron/resources/brands/qwen-code/icon.icon/Assets/icon.svg similarity index 100% rename from apps/electron/resources/icon.icon/Assets/icon.svg rename to apps/electron/resources/brands/qwen-code/icon.icon/Assets/icon.svg diff --git a/apps/electron/resources/icon.icon/icon.json b/apps/electron/resources/brands/qwen-code/icon.icon/icon.json similarity index 100% rename from apps/electron/resources/icon.icon/icon.json rename to apps/electron/resources/brands/qwen-code/icon.icon/icon.json diff --git a/apps/electron/resources/icon.png b/apps/electron/resources/brands/qwen-code/icon.png similarity index 100% rename from apps/electron/resources/icon.png rename to apps/electron/resources/brands/qwen-code/icon.png diff --git a/apps/electron/resources/icon.svg b/apps/electron/resources/brands/qwen-code/icon.svg similarity index 100% rename from apps/electron/resources/icon.svg rename to apps/electron/resources/brands/qwen-code/icon.svg diff --git a/apps/electron/resources/generate-icons.sh b/apps/electron/resources/generate-icons.sh index 7f22f1dcd..442f4bbaa 100755 --- a/apps/electron/resources/generate-icons.sh +++ b/apps/electron/resources/generate-icons.sh @@ -1,21 +1,25 @@ #!/bin/bash # Generate app icons for all platforms from a source PNG -# Usage: ./generate-icons.sh source.png +# Usage: ./generate-icons.sh source.png [brand-id] set -e SOURCE="${1:-source.png}" +BRAND_ID="${2:-qwen-code}" +OUTPUT_DIR="brands/$BRAND_ID" if [ ! -f "$SOURCE" ]; then echo "Error: Source file '$SOURCE' not found" - echo "Usage: ./generate-icons.sh source.png" + echo "Usage: ./generate-icons.sh source.png [brand-id]" exit 1 fi echo "Generating icons from: $SOURCE" +echo "Output brand directory: $OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" # Create temporary iconset directory for macOS -ICONSET="icon.iconset" +ICONSET="$OUTPUT_DIR/icon.iconset" rm -rf "$ICONSET" mkdir -p "$ICONSET" @@ -34,29 +38,29 @@ sips -z 1024 1024 "$SOURCE" --out "$ICONSET/icon_512x512@2x.png" > /dev/null # Generate .icns for macOS echo "Creating icon.icns..." -iconutil -c icns "$ICONSET" -o icon.icns +iconutil -c icns "$ICONSET" -o "$OUTPUT_DIR/icon.icns" # Generate icon.png for Linux (512x512) echo "Creating icon.png for Linux..." -sips -z 512 512 "$SOURCE" --out icon.png > /dev/null +sips -z 512 512 "$SOURCE" --out "$OUTPUT_DIR/icon.png" > /dev/null # Generate icon.ico for Windows using ImageMagick (if available) # If not, we'll create individual PNGs that can be converted online if command -v convert &> /dev/null; then echo "Creating icon.ico for Windows..." # Create multiple sizes for ICO - sips -z 16 16 "$SOURCE" --out icon_16.png > /dev/null - sips -z 24 24 "$SOURCE" --out icon_24.png > /dev/null - sips -z 32 32 "$SOURCE" --out icon_32.png > /dev/null - sips -z 48 48 "$SOURCE" --out icon_48.png > /dev/null - sips -z 64 64 "$SOURCE" --out icon_64.png > /dev/null - sips -z 128 128 "$SOURCE" --out icon_128.png > /dev/null - sips -z 256 256 "$SOURCE" --out icon_256.png > /dev/null + sips -z 16 16 "$SOURCE" --out "$OUTPUT_DIR/icon_16.png" > /dev/null + sips -z 24 24 "$SOURCE" --out "$OUTPUT_DIR/icon_24.png" > /dev/null + sips -z 32 32 "$SOURCE" --out "$OUTPUT_DIR/icon_32.png" > /dev/null + sips -z 48 48 "$SOURCE" --out "$OUTPUT_DIR/icon_48.png" > /dev/null + sips -z 64 64 "$SOURCE" --out "$OUTPUT_DIR/icon_64.png" > /dev/null + sips -z 128 128 "$SOURCE" --out "$OUTPUT_DIR/icon_128.png" > /dev/null + sips -z 256 256 "$SOURCE" --out "$OUTPUT_DIR/icon_256.png" > /dev/null - convert icon_16.png icon_24.png icon_32.png icon_48.png icon_64.png icon_128.png icon_256.png icon.ico + convert "$OUTPUT_DIR/icon_16.png" "$OUTPUT_DIR/icon_24.png" "$OUTPUT_DIR/icon_32.png" "$OUTPUT_DIR/icon_48.png" "$OUTPUT_DIR/icon_64.png" "$OUTPUT_DIR/icon_128.png" "$OUTPUT_DIR/icon_256.png" "$OUTPUT_DIR/icon.ico" # Clean up temp files - rm -f icon_16.png icon_24.png icon_32.png icon_48.png icon_64.png icon_128.png icon_256.png + rm -f "$OUTPUT_DIR"/icon_*.png else echo "Warning: ImageMagick not installed. Skipping .ico generation." echo "Install with: brew install imagemagick" @@ -68,9 +72,9 @@ rm -rf "$ICONSET" echo "" echo "✅ Icons generated:" -ls -la icon.* +ls -la "$OUTPUT_DIR"/icon.* echo "" echo "Next steps:" -echo "1. Update apps/electron/src/main/index.ts to use icon.icns on macOS" +echo "1. Ensure BRAND.assets points to resources/brands/$BRAND_ID/" echo "2. Run: bun run electron:build:resources" diff --git a/apps/electron/resources/icon.icns b/apps/electron/resources/icon.icns deleted file mode 100644 index 7b188a048..000000000 Binary files a/apps/electron/resources/icon.icns and /dev/null differ diff --git a/apps/electron/scripts/afterPack.cjs b/apps/electron/scripts/afterPack.cjs index dbdaffd4f..bae23e940 100644 --- a/apps/electron/scripts/afterPack.cjs +++ b/apps/electron/scripts/afterPack.cjs @@ -5,7 +5,8 @@ * into the app bundle when present. Without it, macOS falls back to icon.icns. * * If a future Icon Composer workflow produces Assets.car for the current app - * icon, place it at resources/Assets.car and this hook will bundle it. + * icon, place it at resources/brands//Assets.car and this hook will + * bundle it. * * For older macOS versions, or builds without Assets.car, the app falls back * to icon.icns which is included separately by electron-builder. @@ -24,14 +25,21 @@ module.exports = async function afterPack(context) { const appPath = context.appOutDir; const productName = context.packager.appInfo.productName; const resourcesDir = path.join(appPath, `${productName}.app`, 'Contents', 'Resources'); - const precompiledAssets = path.join(context.packager.projectDir, 'resources', 'Assets.car'); + const brandId = process.env.CRAFT_BRAND || 'qwen-code'; + const precompiledAssets = path.join( + context.packager.projectDir, + 'resources', + 'brands', + brandId, + 'Assets.car', + ); console.log(`afterPack: projectDir=${context.packager.projectDir}`); console.log(`afterPack: looking for Assets.car at ${precompiledAssets}`); // Check if pre-compiled Assets.car exists if (!fs.existsSync(precompiledAssets)) { - console.log('Warning: Pre-compiled Assets.car not found in resources/'); + console.log(`Warning: Pre-compiled Assets.car not found for brand ${brandId}`); console.log('The app will use the fallback icon.icns on all macOS versions'); return; } diff --git a/apps/electron/scripts/generate-pet-spritesheets.mjs b/apps/electron/scripts/generate-pet-spritesheets.mjs new file mode 100644 index 000000000..85408124a --- /dev/null +++ b/apps/electron/scripts/generate-pet-spritesheets.mjs @@ -0,0 +1,136 @@ +/** + * Generates the built-in pet spritesheet — the Qwen capybara HEAD + * (clean-room original vector art, modeled on the reference design). + * + * Run from the desktop package: node apps/electron/scripts/generate-pet-spritesheets.mjs + * Pass --contact to also emit a QA contact sheet next to the webp. + * + * Output: apps/electron/src/renderer/assets/pets/qwen-spritesheet.webp + * + * The head is drawn in a 1024-unit design space and scaled into each cell. + * Atlas contract (shared with renderer/pets/pet-animation.ts): + * 1536x1872, 8 cols x 9 rows, cell 192x208, transparent background. + * Rows: 0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping, + * 5 failed, 6 waiting, 7 running, 8 review. + */ +import sharp from 'sharp'; +import { mkdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CW = 192, CH = 208, COLS = 8, ROWS = 9; +const AW = CW * COLS, AH = CH * ROWS; + +const OUTLINE = '#250506', FUR = '#D8B778', EARIN = '#E2C58F', MUZZLE = '#C99768'; + +// Fit the 1024-space design into the cell, centred. +const S = 0.31, TX = -62.7, TY = -17.2; +const RCX = 96, RCY = 104; // cell-space rotation centre + +const HEAD = ` + + + + + + +`; +const MUZ = ``; +const WHISKERS = ` + + +`; + +function eyes(style) { + const sw = 18; + if (style === 'happy') { + return `` + + ``; + } + if (style === 'x') { + const x = (cx) => ``; + return x(415) + x(655); + } + if (style === 'blink') { + return ``; + } + const yy = style === 'up' ? -14 : 0; + return `` + + ``; +} +function noseMouth(style) { + const nose = ``; + const phil = ``; + let m; + if (style === 'o') m = ``; + else if (style === 'flat') m = ``; + else if (style === 'frown') m = ``; + else m = ``; + return nose + phil + m; +} + +function spriteSVG(p) { + const dx = p.dx || 0, dy = p.dy || 0, lean = p.lean || 0; + const inner = + `` + + HEAD + eyes(p.eyes || 'open') + MUZ + noseMouth(p.mouth || 'smile') + WHISKERS + + ``; + const fit = `${inner}`; + const anim = `${fit}`; + const flip = p.facing === -1 ? `${anim}` : anim; + return `${flip}`; +} + +const sin = (i, n) => Math.sin((i / n) * Math.PI * 2); +function framesFor(state) { + switch (state) { + case 'idle': { const e = ['open', 'open', 'blink', 'open', 'open', 'open']; return Array.from({ length: 6 }, (_, i) => ({ eyes: e[i], mouth: 'smile', dy: [0, 0, 1, 2, 1, 0][i] })); } + case 'running-right': return Array.from({ length: 8 }, (_, i) => ({ eyes: 'open', mouth: 'flat', facing: 1, dy: -Math.abs(Math.round(2 * sin(i, 8))), lean: 5 * sin(i, 8) })); + case 'running-left': return Array.from({ length: 8 }, () => ({})); + case 'waving': { const t = [6, 9, 6, 3]; return Array.from({ length: 4 }, (_, i) => ({ eyes: 'happy', mouth: 'smile', lean: t[i] })); } + case 'jumping': { const dy = [6, -4, -16, -4, 5]; const e = ['open', 'happy', 'happy', 'happy', 'open']; return Array.from({ length: 5 }, (_, i) => ({ eyes: e[i], mouth: 'smile', dy: dy[i] })); } + case 'failed': { const dx = [0, -2, 2, -2, 2, -1, 1, 0]; return Array.from({ length: 8 }, (_, i) => ({ eyes: 'x', mouth: 'frown', dx: dx[i], dy: 2 })); } + case 'waiting': { const e = ['up', 'up', 'blink', 'up', 'up', 'up']; return Array.from({ length: 6 }, (_, i) => ({ eyes: e[i], mouth: 'o', dy: [0, 1, 1, 0, 0, 1][i], lean: [4, 4, 0, -4, -4, 0][i] })); } + case 'running': { const e = ['open', 'open', 'blink', 'open', 'open', 'open']; return Array.from({ length: 6 }, (_, i) => ({ eyes: e[i], mouth: 'flat', dy: i % 2, lean: 2 * sin(i, 6) })); } + case 'review': { const e = ['blink', 'open', 'open', 'blink', 'open', 'open']; return Array.from({ length: 6 }, (_, i) => ({ eyes: e[i], mouth: 'flat', lean: 4, dx: 2 })); } + default: return [{}]; + } +} +const ROW_STATE = ['idle', 'running-right', 'running-left', 'waving', 'jumping', 'failed', 'waiting', 'running', 'review']; + +async function buildAtlas() { + const composites = []; + const right = []; + for (let row = 0; row < ROWS; row++) { + const state = ROW_STATE[row]; + const frames = framesFor(state); + for (let col = 0; col < frames.length; col++) { + let buf; + if (state === 'running-left') { + buf = await sharp(right[col]).flop().png().toBuffer(); + } else { + buf = await sharp(Buffer.from(spriteSVG(frames[col]))).png().toBuffer(); + if (state === 'running-right') right[col] = buf; + } + composites.push({ input: buf, left: col * CW, top: row * CH }); + } + } + return sharp({ create: { width: AW, height: AH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).composite(composites); +} + +async function main() { + const wantContact = process.argv.includes('--contact'); + const outDir = join(__dirname, '..', 'src', 'renderer', 'assets', 'pets'); + mkdirSync(outDir, { recursive: true }); + const atlas = await buildAtlas(); + const webpPath = join(outDir, 'qwen-spritesheet.webp'); + await atlas.clone().webp({ lossless: true }).toFile(webpPath); + if (wantContact) { + const png = await atlas.clone().png().toBuffer(); + await sharp({ create: { width: AW, height: AH, channels: 4, background: { r: 235, g: 235, b: 238, alpha: 1 } } }) + .composite([{ input: png, left: 0, top: 0 }]).png().toFile(join(outDir, 'qwen-contact.png')); + } + console.log(`wrote qwen-spritesheet.webp (${statSync(webpPath).size} bytes)`); +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/electron/src/main/handlers/__tests__/registration.test.ts b/apps/electron/src/main/handlers/__tests__/registration.test.ts index e6f97d8d6..1b32f3650 100644 --- a/apps/electron/src/main/handlers/__tests__/registration.test.ts +++ b/apps/electron/src/main/handlers/__tests__/registration.test.ts @@ -122,14 +122,21 @@ async function getExpectedChannels(): Promise> { ]); // GUI handler channels (remain in electron) - const [browser, guiSystem, guiWorkspace, guiSettings, guiWindowDrag] = - await Promise.all([ - import('../browser'), - import('../system'), - import('../workspace'), - import('../settings'), - import('../window-drag'), - ]); + const [ + browser, + guiSystem, + guiWorkspace, + guiSettings, + guiWindowDrag, + guiPetWindow, + ] = await Promise.all([ + import('../browser'), + import('../system'), + import('../workspace'), + import('../settings'), + import('../window-drag'), + import('../pet-window'), + ]); return new Set([ ...auth.HANDLED_CHANNELS, @@ -156,6 +163,7 @@ async function getExpectedChannels(): Promise> { ...guiWorkspace.GUI_HANDLED_CHANNELS, ...guiSettings.GUI_HANDLED_CHANNELS, ...guiWindowDrag.GUI_HANDLED_CHANNELS, + ...guiPetWindow.GUI_HANDLED_CHANNELS, ]); } diff --git a/apps/electron/src/main/handlers/index.ts b/apps/electron/src/main/handlers/index.ts index 5c87d52f5..82eec1428 100644 --- a/apps/electron/src/main/handlers/index.ts +++ b/apps/electron/src/main/handlers/index.ts @@ -12,6 +12,7 @@ import { registerWorkspaceGuiHandlers } from './workspace'; import { registerBrowserHandlers } from './browser'; import { registerSettingsGuiHandlers } from './settings'; import { registerWindowDragGuiHandlers } from './window-drag'; +import { registerPetWindowGuiHandlers } from './pet-window'; export function registerGuiRpcHandlers( server: RpcServer, @@ -22,6 +23,7 @@ export function registerGuiRpcHandlers( registerBrowserHandlers(server, deps); registerSettingsGuiHandlers(server, deps); registerWindowDragGuiHandlers(server, deps); + registerPetWindowGuiHandlers(server, deps); } export function registerAllRpcHandlers( diff --git a/apps/electron/src/main/handlers/pet-window.ts b/apps/electron/src/main/handlers/pet-window.ts new file mode 100644 index 000000000..c7c14b2f2 --- /dev/null +++ b/apps/electron/src/main/handlers/pet-window.ts @@ -0,0 +1,53 @@ +import { RPC_CHANNELS } from '@craft-agent/shared/protocol'; +import type { RpcServer } from '@craft-agent/server-core/transport'; +import type { HandlerDeps } from './handler-deps'; + +export const GUI_HANDLED_CHANNELS = [ + RPC_CHANNELS.window.PET_SET_ENABLED, + RPC_CHANNELS.window.PET_SET_IGNORE_MOUSE, + RPC_CHANNELS.window.PET_FOCUS_SESSION, +] as const; + +/** + * GUI handlers for the floating desktop-pet window. The renderer that hosts the + * main UI toggles the window on/off (and reloads it on pet change); the pet + * window itself toggles click-through as the cursor enters/leaves the pet. + */ +export function registerPetWindowGuiHandlers( + server: RpcServer, + deps: HandlerDeps, +): void { + server.handle( + RPC_CHANNELS.window.PET_SET_ENABLED, + (ctx, enabled: boolean) => { + const wm = deps.windowManager; + if (!wm) return; + const workspaceId = + ctx.webContentsId != null + ? (wm.getWorkspaceForWindow(ctx.webContentsId) ?? '') + : ''; + wm.setPetWindowEnabled(Boolean(enabled), workspaceId); + }, + ); + + server.handle( + RPC_CHANNELS.window.PET_SET_IGNORE_MOUSE, + (_ctx, ignore: boolean) => { + deps.windowManager?.setPetWindowIgnoreMouse(Boolean(ignore)); + }, + ); + + // Clicking a pet notification card focuses the main window and navigates to + // the originating session (reuses the OS-notification click path). + server.handle( + RPC_CHANNELS.window.PET_FOCUS_SESSION, + async (ctx, sessionId: string) => { + const wm = deps.windowManager; + if (!wm || !sessionId || ctx.webContentsId == null) return; + const workspaceId = wm.getWorkspaceForWindow(ctx.webContentsId); + if (!workspaceId) return; + const { handleNotificationClick } = await import('../notifications'); + handleNotificationClick(workspaceId, sessionId); + }, + ); +} diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 463817073..598e5c076 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -413,20 +413,21 @@ app.whenReady().then(async () => { // Set dock icon on macOS in dev mode; packaged apps use Info.plist/.icns. if (process.platform === 'darwin' && app.dock && !app.isPackaged) { // In dev, resources are at ../resources/ (sibling of dist/) - // Brand-aware: use brand-specific icon when available, fall back to default - const brandIconRelPath = BRAND.id === 'qwen-code' ? 'resources/icon.png' : `resources/brands/${BRAND.id}/icon.png` - const dockIconPath = [ - join(__dirname, brandIconRelPath), - join(__dirname, '..', brandIconRelPath), - // Fallback to default icon if brand-specific one is missing - join(__dirname, 'resources/icon.png'), - join(__dirname, '../resources/icon.png'), - ].find(p => existsSync(p)) - - if (dockIconPath) { - app.dock.setIcon(dockIconPath) - // Initialize badge icon for canvas-based badge overlay - initBadgeIcon(dockIconPath) + const brandIconRelPath = BRAND.assets.devDockIcon + if (brandIconRelPath) { + const dockIconPath = [ + join(__dirname, brandIconRelPath), + join(__dirname, '..', brandIconRelPath), + ].find(p => existsSync(p)) + + if (dockIconPath) { + const dockIcon = nativeImage.createFromPath(dockIconPath) + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon) + // Initialize badge icon for canvas-based badge overlay + initBadgeIcon(dockIconPath) + } + } } // Multi-instance dev: show instance number badge on dock icon diff --git a/apps/electron/src/main/menu.ts b/apps/electron/src/main/menu.ts index b4d151bbf..e9bf01489 100644 --- a/apps/electron/src/main/menu.ts +++ b/apps/electron/src/main/menu.ts @@ -48,7 +48,11 @@ export async function rebuildMenu(): Promise { const windowManager = cachedWindowManager const isMac = process.platform === 'darwin' - const homepageUrl = BRAND.homepageUrl + const helpMenuLinks: Electron.MenuItemConstructorOptions[] = + BRAND.helpMenuLinks.map((link) => ({ + label: i18n.t(link.labelKey), + click: () => shell.openExternal(link.url), + })) // On Windows/Linux, hide the native menu entirely // Users access menu via the Craft logo dropdown in the app @@ -192,10 +196,8 @@ export async function rebuildMenu(): Promise { { label: i18n.t("menu.help"), submenu: [ - ...(homepageUrl ? [{ - label: 'Homepage', - click: () => shell.openExternal(homepageUrl) - }] : []), + ...helpMenuLinks, + ...(helpMenuLinks.length > 0 ? [{ type: 'separator' as const }] : []), { label: i18n.t("menu.keyboardShortcuts"), accelerator: 'CmdOrCtrl+/', diff --git a/apps/electron/src/main/notifications.ts b/apps/electron/src/main/notifications.ts index 9501a2158..3128e8d8b 100644 --- a/apps/electron/src/main/notifications.ts +++ b/apps/electron/src/main/notifications.ts @@ -9,7 +9,6 @@ import { Notification, app, BrowserWindow, nativeImage } from 'electron' import { join } from 'path' -import { readFileSync } from 'fs' import { mainLog } from './logger' import { RPC_CHANNELS } from '../shared/types' import type { WindowManager } from './window-manager' @@ -83,7 +82,10 @@ export function showNotification( /** * Handle notification click - focus window and navigate to session */ -function handleNotificationClick(workspaceId: string, sessionId: string): void { +export function handleNotificationClick( + workspaceId: string, + sessionId: string, +): void { if (!windowManager) { mainLog.error('WindowManager not initialized for notification click') return @@ -131,8 +133,14 @@ function handleNotificationClick(workspaceId: string, sessionId: string): void { export function initBadgeIcon(iconPath: string): void { try { baseIconPath = iconPath - // Read and cache the icon as base64 data URL - const iconBuffer = readFileSync(iconPath) + const icon = nativeImage.createFromPath(iconPath) + if (icon.isEmpty()) { + baseIconDataUrl = null + mainLog.warn('Badge icon could not be loaded:', iconPath) + return + } + + const iconBuffer = icon.toPNG() baseIconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}` mainLog.info('Badge icon initialized:', iconPath) } catch (error) { diff --git a/apps/electron/src/main/window-manager.ts b/apps/electron/src/main/window-manager.ts index ed3c8935a..86f64a057 100644 --- a/apps/electron/src/main/window-manager.ts +++ b/apps/electron/src/main/window-manager.ts @@ -1,10 +1,15 @@ -import { BrowserWindow, shell, nativeTheme, Menu, app } from 'electron' +import { BrowserWindow, shell, nativeTheme, Menu, app, screen } from 'electron' import { windowLog } from './logger' import { join } from 'path' import { existsSync } from 'fs' import { release } from 'os' import { RPC_CHANNELS, type WindowCloseRequestSource } from '../shared/types' +import { BRAND } from '@craft-agent/shared/branding' import type { SavedWindow } from './window-state' +import { + getPetWindowBounds, + setPetWindowBounds, +} from '@craft-agent/shared/config/storage' // Vite dev server URL for hot reload const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL @@ -52,6 +57,8 @@ export interface CreateWindowOptions { export class WindowManager { private windows: Map = new Map() // webContents.id → ManagedWindow + private petWindow: BrowserWindow | null = null // floating desktop-pet window (not a managed workspace window) + private petWorkspaceId = '' // workspace the pet window subscribes to for activity private focusedModeWindows: Set = new Set() // webContents.id of windows in focused mode private pendingCloseTimeouts: Map = new Map() // Fallback timeouts for window close private eventSink: ((channel: string, target: import('@craft-agent/shared/protocol').PushTarget, ...args: any[]) => void) | null = null @@ -108,13 +115,13 @@ export class WindowManager { // In packaged app, resources are at dist/resources/ (same level as __dirname) // In dev, resources are at ../resources/ (sibling of dist/) const getIconPath = () => { - const iconName = process.platform === 'darwin' ? 'icon.icns' - : process.platform === 'win32' ? 'icon.ico' - : 'icon.png' + const iconPath = process.platform === 'darwin' ? BRAND.assets.macIcon + : process.platform === 'win32' ? BRAND.assets.winIcon + : BRAND.assets.linuxIcon return [ - join(__dirname, 'resources', iconName), - join(__dirname, '../resources', iconName), - ].find(p => existsSync(p)) ?? join(__dirname, '../resources', iconName) + join(__dirname, iconPath), + join(__dirname, '..', iconPath), + ].find(p => existsSync(p)) ?? join(__dirname, '..', iconPath) } const iconPath = getIconPath() @@ -411,6 +418,13 @@ export class WindowManager { * Get window by webContents.id (used by IPC handlers instead of BrowserWindow.fromId) */ getWindowByWebContentsId(wcId: number): BrowserWindow | null { + if ( + this.petWindow && + !this.petWindow.isDestroyed() && + this.petWindow.webContents.id === wcId + ) { + return this.petWindow + } const managed = this.windows.get(wcId) return managed?.window ?? null } @@ -450,6 +464,13 @@ export class WindowManager { * Get workspace ID for a window (by webContents.id) */ getWorkspaceForWindow(webContentsId: number): string | null { + if ( + this.petWindow && + !this.petWindow.isDestroyed() && + this.petWindow.webContents.id === webContentsId + ) { + return this.petWorkspaceId + } const managed = this.windows.get(webContentsId) return managed?.workspaceId ?? null } @@ -545,6 +566,127 @@ export class WindowManager { windowLog.info(`Registered window ${webContentsId} for workspace ${workspaceId}`) } + // ---- Floating desktop-pet window ------------------------------------- + + /** The live pet window, or null. */ + getPetWindow(): BrowserWindow | null { + return this.petWindow && !this.petWindow.isDestroyed() + ? this.petWindow + : null + } + + /** Toggle click-through on the pet window (called as the cursor enters/leaves the pet). */ + setPetWindowIgnoreMouse(ignore: boolean): void { + this.getPetWindow()?.setIgnoreMouseEvents(ignore, { forward: true }) + } + + /** + * Show/hide the floating pet window. When already shown, reloads it so a + * newly-selected pet takes effect. The pet window is intentionally NOT a + * managed workspace window (excluded from state persistence + quit logic). + */ + setPetWindowEnabled(enabled: boolean, workspaceId: string): void { + if (!enabled) { + if (this.petWindow && !this.petWindow.isDestroyed()) { + this.petWindow.destroy() + } + this.petWindow = null + return + } + this.petWorkspaceId = workspaceId + const existing = this.getPetWindow() + if (existing) { + this.loadPetWindow(existing, workspaceId) + existing.showInactive() + return + } + this.createPetWindow(workspaceId) + } + + private loadPetWindow(window: BrowserWindow, workspaceId: string): void { + if (VITE_DEV_SERVER_URL) { + const params = new URLSearchParams({ workspaceId }).toString() + void window.loadURL(`${VITE_DEV_SERVER_URL}/pet.html?${params}`) + } else { + void window.loadFile(join(__dirname, 'renderer/pet.html'), { + query: { workspaceId }, + }) + } + } + + private defaultPetPosition( + width: number, + height: number, + ): { x: number; y: number } { + const area = screen.getPrimaryDisplay().workArea + return { + x: Math.round(area.x + area.width - width - 24), + y: Math.round(area.y + area.height - height - 24), + } + } + + private createPetWindow(workspaceId: string): void { + // Tall/wide enough to stack notification cards above the pet; the window is + // transparent + click-through so the empty area is invisible and inert. + const width = 380 + const height = 540 + const saved = getPetWindowBounds() + const { x, y } = saved ?? this.defaultPetPosition(width, height) + + const window = new BrowserWindow({ + width, + height, + x, + y, + show: false, + frame: false, + transparent: true, + resizable: false, + maximizable: false, + minimizable: false, + fullscreenable: false, + skipTaskbar: true, + hasShadow: false, + alwaysOnTop: true, + title: 'Qwen Pet', + webPreferences: { + preload: join(__dirname, 'bootstrap-preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + webviewTag: false, + }, + }) + + window.setAlwaysOnTop(true, 'floating') + if (process.platform === 'darwin') { + // skipTransformProcessType: without it, setVisibleOnAllWorkspaces flips the + // whole app to NSApplicationActivationPolicyAccessory, which removes the + // main Dock icon a moment after the pet window shows. Keep the process type. + window.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }) + } + + // Register BEFORE load so the bootstrap preload's __get-workspace-id resolves. + this.petWindow = window + this.petWorkspaceId = workspaceId + + window.once('ready-to-show', () => window.showInactive()) + window.on('moved', () => { + if (window.isDestroyed()) return + const [px, py] = window.getPosition() + setPetWindowBounds({ x: px, y: py }) + }) + window.on('closed', () => { + if (this.petWindow === window) this.petWindow = null + }) + + this.loadPetWindow(window, workspaceId) + windowLog.info(`Created pet window for workspace ${workspaceId}`) + } + /** * Get all managed windows */ diff --git a/apps/electron/src/renderer/assets/craft_logo_c.svg b/apps/electron/src/renderer/assets/craft_logo_c.svg deleted file mode 100644 index 0618ac714..000000000 --- a/apps/electron/src/renderer/assets/craft_logo_c.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - craft_logo_c - - - - - - - - - \ No newline at end of file diff --git a/apps/electron/src/renderer/assets/pets/qwen-spritesheet.webp b/apps/electron/src/renderer/assets/pets/qwen-spritesheet.webp new file mode 100644 index 000000000..94d668752 Binary files /dev/null and b/apps/electron/src/renderer/assets/pets/qwen-spritesheet.webp differ diff --git a/apps/electron/src/renderer/components/AppMenu.tsx b/apps/electron/src/renderer/components/AppMenu.tsx index a0dec3fdf..a489f11ae 100644 --- a/apps/electron/src/renderer/components/AppMenu.tsx +++ b/apps/electron/src/renderer/components/AppMenu.tsx @@ -28,9 +28,6 @@ import { } from "../../shared/menu-schema" import type { MenuItem, MenuSection, SettingsMenuItem } from "../../shared/menu-schema" import { SETTINGS_ICONS } from "./icons/SettingsIcons" -import { getDocUrl } from '@craft-agent/shared/docs/doc-links' - -const brandHomepageUrl = BRAND.homepageUrl // Map of action handlers for menu items that need custom behavior type MenuActionHandlers = { @@ -183,6 +180,7 @@ export function AppMenu({ }: AppMenuProps) { const { t } = useTranslation() const [isDebugMode, setIsDebugMode] = useState(false) + const hasHelpMenuLinks = BRAND.helpMenuLinks.length > 0 // Get hotkey labels from centralized action registry const newChatHotkey = useActionLabel('app.newChat').hotkey @@ -274,19 +272,20 @@ export function AppMenu({ Help - {brandHomepageUrl && ( - window.electronAPI.openUrl(brandHomepageUrl)}> - - Homepage - - - )} - window.electronAPI.openUrl(getDocUrl('automations'))}> - - Automations - - - + {BRAND.helpMenuLinks.map((link) => { + const Icon = getIcon(link.icon) ?? Icons.ExternalLink + return ( + window.electronAPI.openUrl(link.url)} + > + + {t(link.labelKey)} + + + ) + })} + {hasHelpMenuLinks && } Keyboard Shortcuts diff --git a/apps/electron/src/renderer/components/app-shell/TopBar.tsx b/apps/electron/src/renderer/components/app-shell/TopBar.tsx index d9673f044..88051e0bc 100644 --- a/apps/electron/src/renderer/components/app-shell/TopBar.tsx +++ b/apps/electron/src/renderer/components/app-shell/TopBar.tsx @@ -48,8 +48,6 @@ import type { ViewRoute } from '../../../shared/routes'; // --- Menu rendering (moved from AppMenu) --- -const brandHomepageUrl = BRAND.homepageUrl; - type MenuActionHandlers = { toggleFocusMode?: () => void; toggleSidebar?: () => void; @@ -203,6 +201,7 @@ export function TopBar({ }: TopBarProps) { const { t } = useTranslation(); const [isDebugMode, setIsDebugMode] = useState(false); + const hasHelpMenuLinks = BRAND.helpMenuLinks.length > 0; const newChatHotkey = useActionLabel('app.newChat').hotkey; const newWindowHotkey = useActionLabel('app.newWindow').hotkey; @@ -327,17 +326,20 @@ export function TopBar({ {t('menu.help')} - {brandHomepageUrl && ( - - window.electronAPI.openUrl(brandHomepageUrl) - } - > - - Homepage - - - )} + {BRAND.helpMenuLinks.map((link) => { + const Icon = getIcon(link.icon) ?? Icons.ExternalLink; + return ( + window.electronAPI.openUrl(link.url)} + > + + {t(link.labelKey)} + + + ); + })} + {hasHelpMenuLinks && } {t('menu.keyboardShortcuts')} diff --git a/apps/electron/src/renderer/components/app-shell/WorkspaceProjectTree.tsx b/apps/electron/src/renderer/components/app-shell/WorkspaceProjectTree.tsx index 1a89a9d45..d620b5a06 100644 --- a/apps/electron/src/renderer/components/app-shell/WorkspaceProjectTree.tsx +++ b/apps/electron/src/renderer/components/app-shell/WorkspaceProjectTree.tsx @@ -1,8 +1,9 @@ import * as React from "react" import { useTranslation } from "react-i18next" +// eslint-disable-next-line import/no-internal-modules import { AnimatePresence } from "motion/react" import { useSetAtom } from "jotai" -import { ChevronDown, ChevronRight, Cloud, ExternalLink, Flag, Folder, FolderPlus, MessageSquare, Pencil, Pin, PinOff, Trash2 } from "lucide-react" +import { ChevronDown, ChevronRight, Cloud, ExternalLink, Flag, Folder, FolderPlus, GitBranch, MessageSquare, Pencil, Pin, PinOff, Trash2 } from "lucide-react" import { toast } from "sonner" import { cn } from "@/lib/utils" @@ -20,6 +21,16 @@ import { StyledContextMenuItem, StyledContextMenuSeparator, } from "@/components/ui/styled-context-menu" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" import { ContextMenuProvider } from "@/components/ui/menu-context" import { RenameDialog } from "@/components/ui/rename-dialog" import { SessionMenu } from "./SessionMenu" @@ -83,6 +94,11 @@ interface ProjectSessionMenuConfig { const PROJECT_SESSION_PREVIEW_LIMIT = 5 +function getDefaultWorktreeBranchName(workspace: Workspace, t: (key: string, defaultValue: string) => string): string { + const name = getWorkspaceDisplayName(workspace, t).trim() + return `${name || "worktree"}_2` +} + function WorkspaceHeader({ workspace, displayName, @@ -97,12 +113,14 @@ function WorkspaceHeader({ renameLabel, pinLabel, unpinLabel, + createWorktreeLabel, removeLabel, onToggleCollapsed, onNewSession, onOpenInNewWindow, onRename, onTogglePinned, + onCreateWorktree, onRemove, }: { workspace: Workspace @@ -118,12 +136,14 @@ function WorkspaceHeader({ renameLabel: string pinLabel: string unpinLabel: string + createWorktreeLabel: string removeLabel: string onToggleCollapsed: () => void onNewSession: () => void onOpenInNewWindow: () => void onRename: () => void onTogglePinned: () => void + onCreateWorktree: () => void onRemove: () => void }) { const header = ( @@ -208,6 +228,12 @@ function WorkspaceHeader({ {isPinned ? : } {isPinned ? unpinLabel : pinLabel} + {!workspace.remoteServer && ( + + + {createWorktreeLabel} + + )} )} @@ -391,6 +417,10 @@ export function WorkspaceProjectTree({ const [collapsedWorkspaceIds, setCollapsedWorkspaceIds] = React.useState>(() => new Set()) const [expandedWorkspaceSessionIds, setExpandedWorkspaceSessionIds] = React.useState>(() => new Set()) const [optimisticWorkspaceOrder, setOptimisticWorkspaceOrder] = React.useState(null) + const [createWorktreeDialogOpen, setCreateWorktreeDialogOpen] = React.useState(false) + const [createWorktreeWorkspaceId, setCreateWorktreeWorkspaceId] = React.useState(null) + const [createWorktreeBranchName, setCreateWorktreeBranchName] = React.useState("") + const [creatingWorktree, setCreatingWorktree] = React.useState(false) const hasRemoteWorkspaces = React.useMemo(() => workspaces.some(workspace => workspace.remoteServer), [workspaces]) const workspaceOrderKey = React.useMemo(() => workspaces.map(workspace => workspace.id).join("\0"), [workspaces]) @@ -459,6 +489,46 @@ export function WorkspaceProjectTree({ void onSelectWorkspace(workspace.id) }, [onSelectWorkspace, onWorkspaceCreated, setFullscreenOverlayOpen, t]) + const handleCreateWorktreeClick = React.useCallback((workspace: Workspace) => { + if (isProtectedWorkspace(workspace) || workspace.remoteServer) return + setCreateWorktreeWorkspaceId(workspace.id) + setCreateWorktreeBranchName(getDefaultWorktreeBranchName(workspace, t)) + requestAnimationFrame(() => { + setCreateWorktreeDialogOpen(true) + }) + }, [t]) + + const handleCreateWorktreeDialogOpenChange = React.useCallback((open: boolean) => { + setCreateWorktreeDialogOpen(open) + if (!open) { + setCreateWorktreeWorkspaceId(null) + setCreateWorktreeBranchName("") + } + }, []) + + const handleCreateWorktreeSubmit = React.useCallback(async () => { + const branchName = createWorktreeBranchName.trim() + if (!createWorktreeWorkspaceId || !branchName || creatingWorktree) return + + setCreatingWorktree(true) + try { + const workspace = await window.electronAPI.createPermanentWorktree(createWorktreeWorkspaceId, branchName) + toast.success(t("toast.createdWorktreeWorkspace", { name: workspace.name })) + setCreateWorktreeDialogOpen(false) + setCreateWorktreeWorkspaceId(null) + setCreateWorktreeBranchName("") + onWorkspaceCreated?.(workspace) + void onSelectWorkspace(workspace.id) + } catch (error) { + const message = error instanceof Error ? error.message : t("toast.unknownError") + toast.error(t("toast.failedToCreateWorktreeWorkspace"), { + description: message, + }) + } finally { + setCreatingWorktree(false) + } + }, [createWorktreeBranchName, createWorktreeWorkspaceId, creatingWorktree, onSelectWorkspace, onWorkspaceCreated, t]) + const handleRenameClick = React.useCallback((sessionId: string, currentName: string) => { setRenameSessionId(sessionId) setRenameName(currentName) @@ -722,12 +792,14 @@ export function WorkspaceProjectTree({ renameLabel={t("common.rename")} pinLabel={t("workspace.pinWorkspace")} unpinLabel={t("workspace.unpinWorkspace")} + createWorktreeLabel={t("workspace.createPermanentWorktree")} removeLabel={t("workspace.removeWorkspace")} onToggleCollapsed={() => toggleWorkspaceCollapsed(workspace.id)} onNewSession={() => handleNewProjectSession(workspace.id)} onOpenInNewWindow={() => void onSelectWorkspace(workspace.id, true)} onRename={() => handleWorkspaceRenameClick(workspace)} onTogglePinned={() => void handleToggleWorkspacePinned(workspace)} + onCreateWorktree={() => handleCreateWorktreeClick(workspace)} onRemove={() => void handleRemoveWorkspace(workspace)} /> {!isSorting && !isCollapsed && sessions.length > 0 ? ( @@ -810,6 +882,52 @@ export function WorkspaceProjectTree({ )} + + +
{ + event.preventDefault() + void handleCreateWorktreeSubmit() + }} + > + + + {t("workspace.createWorktreeDialogTitle")} + + + {t("workspace.createWorktreeDialogDescription")} + + + setCreateWorktreeBranchName(event.target.value)} + disabled={creatingWorktree} + aria-label={t("workspace.branchNameLabel")} + placeholder={t("workspace.branchNamePlaceholder")} + className="h-12 text-base" + /> + + + + +
+
+
+
diff --git a/apps/electron/src/renderer/components/icons/CraftAgentsSymbol.tsx b/apps/electron/src/renderer/components/icons/CraftAgentsSymbol.tsx index ba9882584..f535ba639 100644 --- a/apps/electron/src/renderer/components/icons/CraftAgentsSymbol.tsx +++ b/apps/electron/src/renderer/components/icons/CraftAgentsSymbol.tsx @@ -1,38 +1,24 @@ import { BRAND } from '@craft-agent/shared/branding' -import modelStudioIcon from '@/assets/modelstudio-icon.png' + +const brandSymbols = import.meta.glob( + '../../../../resources/brands/*/{symbol.png,icon.svg}', + { + eager: true, + import: 'default', + }, +) as Record interface CraftAgentsSymbolProps { className?: string } -/** - * Brand-aware app symbol. - * Renders the appropriate logo based on the active brand config. - */ export function CraftAgentsSymbol({ className }: CraftAgentsSymbolProps) { - if (BRAND.id === 'modelstudio') { - return ( - {BRAND.appName} - ) - } - return ( - - - + draggable={false} + /> ) } diff --git a/apps/electron/src/renderer/components/icons/CraftAppIcon.tsx b/apps/electron/src/renderer/components/icons/CraftAppIcon.tsx deleted file mode 100644 index 7a91c2c6d..000000000 --- a/apps/electron/src/renderer/components/icons/CraftAppIcon.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import craftLogo from "@/assets/craft_logo_c.svg" - -interface CraftAppIconProps { - className?: string - size?: number -} - -/** - * CraftAppIcon - Displays the Craft logo (colorful "C" icon) - */ -export function CraftAppIcon({ className, size = 64 }: CraftAppIconProps) { - return ( - Craft - ) -} diff --git a/apps/electron/src/renderer/components/pet/DesktopPet.tsx b/apps/electron/src/renderer/components/pet/DesktopPet.tsx new file mode 100644 index 000000000..6faae9c31 --- /dev/null +++ b/apps/electron/src/renderer/components/pet/DesktopPet.tsx @@ -0,0 +1,271 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronUp, Maximize2 } from 'lucide-react'; +import { usePetCompanion } from '@/pets/usePetCompanion'; +import { usePetActivityState } from '@/pets/usePetActivityState'; +import { usePetNotifications } from '@/pets/usePetNotifications'; +import { normalizePetSize } from '@/pets/pet-size'; +import { PetNotifications } from './PetNotifications'; +import { QwenPet } from './QwenPet'; + +function ignoreDragError(promise: Promise | undefined): void { + void promise?.catch(() => {}); +} + +type ResizeState = { + pointerId: number; + startScreenX: number; + startScreenY: number; + startSize: number; +}; + +/** + * Fills the transparent, always-on-top pet window. Everything is clustered at + * the bottom-right: notification cards stack just above a small toggle, which + * sits just above the draggable pet. The toggle is pinned right above the pet, + * so collapse/expand only grows/shrinks the cards above it — the toggle and pet + * never move. + * + * Click-through is per-element via elementFromPoint: only the pet, the cards + * and the toggle are interactive; everything else passes through to the desktop. + */ +export function DesktopPet() { + const { t } = useTranslation(); + const { selectedPet, petEnabled, petSize, setPetEnabled, setPetSize } = + usePetCompanion(); + const state = usePetActivityState(); + const { items, dismiss } = usePetNotifications(); + const [collapsed, setCollapsed] = useState(false); + const [resizePreview, setResizePreview] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + } | null>(null); + + const ignoringRef = useRef(true); + const draggingRef = useRef(false); + const resizingRef = useRef(null); + const resizePreviewRef = useRef(null); + + const setIgnore = useCallback((ignore: boolean) => { + if (ignore === ignoringRef.current) return; + ignoringRef.current = ignore; + ignoreDragError(window.electronAPI?.petWindowSetIgnoreMouse?.(ignore)); + }, []); + + useEffect(() => { + setIgnore(true); + const onMove = (event: MouseEvent) => { + if (draggingRef.current || resizingRef.current) return; + const el = document.elementFromPoint(event.clientX, event.clientY); + const interactive = !!el?.closest?.('[data-pet-interactive]'); + if (contextMenu && !interactive) setContextMenu(null); + setIgnore(!interactive); + }; + window.addEventListener('mousemove', onMove); + return () => { + window.removeEventListener('mousemove', onMove); + ignoreDragError(window.electronAPI?.petWindowSetIgnoreMouse?.(false)); + }; + }, [contextMenu, setIgnore]); + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + setContextMenu(null); + draggingRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + ignoreDragError( + window.electronAPI?.beginWindowDrag?.(event.screenX, event.screenY), + ); + }, + [], + ); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + if ((event.buttons & 1) === 0) return; + ignoreDragError( + window.electronAPI?.moveWindowDrag?.(event.screenX, event.screenY), + ); + }, + [], + ); + + const onPointerEnd = useCallback( + (event: ReactPointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + draggingRef.current = false; + ignoreDragError(window.electronAPI?.endWindowDrag?.()); + }, + [], + ); + + const updateResizePreview = useCallback((size: number) => { + const normalized = normalizePetSize(size); + resizePreviewRef.current = normalized; + setResizePreview(normalized); + }, []); + + const onResizePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + setContextMenu(null); + resizingRef.current = { + pointerId: event.pointerId, + startScreenX: event.screenX, + startScreenY: event.screenY, + startSize: resizePreviewRef.current ?? petSize, + }; + event.currentTarget.setPointerCapture(event.pointerId); + updateResizePreview(resizePreviewRef.current ?? petSize); + }, + [petSize, updateResizePreview], + ); + + const onPetContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setContextMenu({ + x: Math.min(Math.max(8, event.clientX), window.innerWidth - 100), + y: Math.min(Math.max(8, event.clientY), window.innerHeight - 44), + }); + }, + [], + ); + + const onClosePet = useCallback(() => { + setContextMenu(null); + setPetEnabled(false); + ignoreDragError(window.electronAPI?.setPetWindowEnabled?.(false)); + }, [setPetEnabled]); + + const onResizePointerMove = useCallback( + (event: ReactPointerEvent) => { + const resize = resizingRef.current; + if (!resize || resize.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + const deltaX = event.screenX - resize.startScreenX; + const deltaY = event.screenY - resize.startScreenY; + const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; + updateResizePreview(resize.startSize + delta); + }, + [updateResizePreview], + ); + + const onResizePointerEnd = useCallback( + (event: ReactPointerEvent) => { + const resize = resizingRef.current; + if (!resize || resize.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const nextSize = resizePreviewRef.current ?? resize.startSize; + resizingRef.current = null; + resizePreviewRef.current = null; + setResizePreview(null); + setPetSize(nextSize); + }, + [setPetSize], + ); + + if (!petEnabled) return null; + + const displayPetSize = resizePreview ?? petSize; + + return ( +
+ {items.length > 0 && !collapsed && ( + + )} + + {items.length > 0 && ( + + )} + +
+ + +
+ + {contextMenu && ( +
event.preventDefault()} + > + +
+ )} +
+ ); +} diff --git a/apps/electron/src/renderer/components/pet/PetNotifications.tsx b/apps/electron/src/renderer/components/pet/PetNotifications.tsx new file mode 100644 index 000000000..a9e5927b2 --- /dev/null +++ b/apps/electron/src/renderer/components/pet/PetNotifications.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Check, Clock, Loader2, X } from 'lucide-react'; +import type { + PetNotification, + PetNotificationKind, +} from '@/pets/usePetNotifications'; + +function StatusIcon({ kind }: { kind: PetNotificationKind }) { + const base = 'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white'; + if (kind === 'running') + return ; + if (kind === 'success') + return ; + if (kind === 'error') + return ; + if (kind === 'pending') + return ; + return ; +} + +interface Props { + items: PetNotification[]; + dismiss: (sessionId: string) => void; +} + +/** + * The list of notification cards (newest first). Caps its height and scrolls + * when there are many; collapse state + the toggle live in the parent so the + * toggle stays pinned regardless of the list. + */ +export function PetNotifications({ items, dismiss }: Props) { + const { t } = useTranslation(); + + const focus = (sessionId: string) => { + if (sessionId) void window.electronAPI?.petFocusSession?.(sessionId); + }; + + return ( + // padding + matching negative margin gives card shadows room without + // clipping inside the scroll viewport. +
+ {items.map((n, i) => ( +
focus(n.sessionId)} + className="group relative flex shrink-0 cursor-pointer items-center gap-2.5 rounded-2xl bg-white px-3.5 py-2 shadow-strong ring-1 ring-black/5 transition-colors hover:bg-neutral-50" + > +
+ {i === 0 && ( + + {t('pet.notify.latest')} + + )} +
+ {t(n.titleKey)} +
+
+ + +
+ ))} +
+ ); +} diff --git a/apps/electron/src/renderer/components/pet/PetWindowController.tsx b/apps/electron/src/renderer/components/pet/PetWindowController.tsx new file mode 100644 index 000000000..466a244d7 --- /dev/null +++ b/apps/electron/src/renderer/components/pet/PetWindowController.tsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import { usePetCompanion } from '@/pets/usePetCompanion'; + +/** + * Headless. Mirrors the pet's enabled state + current selection into the + * separate always-on-top desktop window owned by the main process. Re-runs on + * selection change so the main process can reload the window with the new pet. + */ +export function PetWindowController() { + const { petEnabled, petSettingsLoaded, selectedPetId } = usePetCompanion(); + + useEffect(() => { + if (!petSettingsLoaded) return; + void window.electronAPI?.setPetWindowEnabled?.(petEnabled); + }, [petEnabled, petSettingsLoaded, selectedPetId]); + + return null; +} diff --git a/apps/electron/src/renderer/components/pet/QwenPet.tsx b/apps/electron/src/renderer/components/pet/QwenPet.tsx new file mode 100644 index 000000000..8ca751084 --- /dev/null +++ b/apps/electron/src/renderer/components/pet/QwenPet.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; +import { useReducedMotion } from '@/hooks/useReducedMotion'; +import { + PET_BACKGROUND_SIZE, + PET_CELL_HEIGHT, + PET_CELL_WIDTH, + backgroundPositionFor, + buildSequence, + type PetState, +} from '@/pets/pet-animation'; + +interface QwenPetProps { + /** URL or data URL of the 8x9 sprite atlas. */ + spritesheetUrl: string; + state?: PetState; + /** Rendered height in px; width derives from the cell aspect ratio. */ + size?: number; + className?: string; + /** Force a single static frame regardless of the OS motion preference. */ + staticFrame?: boolean; +} + +/** + * Renders one animated pet by stepping a sprite atlas via `background-position`. + * A timeout chain (rather than CSS steps) lets each frame carry its own + * duration and lets non-idle states settle back into the idle loop. + */ +export function QwenPet({ + spritesheetUrl, + state = 'idle', + size = 72, + className, + staticFrame, +}: QwenPetProps) { + const ref = useRef(null); + const prefersReduced = useReducedMotion(); + const reduced = Boolean(staticFrame) || prefersReduced; + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const { frames, loopStartIndex } = buildSequence(state, reduced); + let index = 0; + let timer: ReturnType | null = null; + + el.style.backgroundPosition = backgroundPositionFor(frames[0]); + if (frames.length <= 1) return; + + const schedule = () => { + timer = setTimeout(() => { + const next = index + 1; + if (next >= frames.length) { + if (loopStartIndex != null) { + index = loopStartIndex; + el.style.backgroundPosition = backgroundPositionFor(frames[index]); + schedule(); + } else { + timer = null; + } + return; + } + index = next; + el.style.backgroundPosition = backgroundPositionFor(frames[index]); + schedule(); + }, frames[index].durationMs); + }; + + schedule(); + return () => { + if (timer != null) clearTimeout(timer); + }; + }, [state, reduced, spritesheetUrl]); + + const width = Math.round(size * (PET_CELL_WIDTH / PET_CELL_HEIGHT)); + + return ( +
+ ); +} diff --git a/apps/electron/src/renderer/hooks/useReducedMotion.ts b/apps/electron/src/renderer/hooks/useReducedMotion.ts new file mode 100644 index 000000000..3af8dfd1b --- /dev/null +++ b/apps/electron/src/renderer/hooks/useReducedMotion.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +const QUERY = '(prefers-reduced-motion: reduce)'; + +function matches(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(QUERY).matches + ); +} + +/** Tracks the OS "reduce motion" accessibility preference. */ +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(matches); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + const mq = window.matchMedia(QUERY); + const handler = (event: MediaQueryListEvent) => setReduced(event.matches); + mq.addEventListener('change', handler); + setReduced(mq.matches); + return () => mq.removeEventListener('change', handler); + }, []); + + return reduced; +} diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 3faaf294b..9ead80381 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -8,6 +8,7 @@ import App from './App' import { ThemeProvider } from './context/ThemeContext' import { windowWorkspaceIdAtom } from './atoms/sessions' import { Toaster } from '@/components/ui/sonner' +import { PetWindowController } from '@/components/pet/PetWindowController' import { setupI18n } from '@craft-agent/shared/i18n' import { BRAND } from '@craft-agent/shared/branding' import { initReactI18next } from 'react-i18next' @@ -107,6 +108,7 @@ function Root() { + ) } diff --git a/apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx b/apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx index 739d5f16e..874a7995f 100644 --- a/apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx +++ b/apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx @@ -16,7 +16,7 @@ import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopo import { useTheme } from '@/context/ThemeContext' import { useAppShellContext } from '@/context/AppShellContext' import { routes } from '@/lib/navigate' -import { Monitor, Sun, Moon } from 'lucide-react' +import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react' import type { DetailsPageMeta } from '@/lib/navigation-registry' import type { ToolIconMapping } from '../../../shared/types' @@ -28,6 +28,8 @@ import { SettingsMenuSelect, SettingsToggle, } from '@/components/settings' +import { usePetCompanion } from '@/pets/usePetCompanion' +import { QwenPet } from '@/components/pet/QwenPet' import { useWorkspaceIcons } from '@/hooks/useWorkspaceIcon' import { Info_DataTable, SortableHeader } from '@/components/info/Info_DataTable' import { Info_Badge } from '@/components/info/Info_Badge' @@ -137,6 +139,30 @@ export default function AppearanceSettingsPage() { await window.electronAPI?.setRichToolDescriptions?.(checked) }, []) + // Pet companion settings + custom pets (synced via shared Jotai atoms) + const { + pets, + selectedPetId, + setSelectedPetId, + petEnabled, + setPetEnabled, + refreshCustomPets, + } = usePetCompanion() + const [petsFolder, setPetsFolder] = useState(null) + useEffect(() => { + window.electronAPI?.getHomeDir?.().then((home) => + setPetsFolder(`${home}/.qwen/pets`), + ) + }, []) + const handleOpenPetsFolder = useCallback(async () => { + try { + const path = await window.electronAPI?.openPetsFolder?.() + if (path) setPetsFolder(path) + } catch { + // Keep the settings page usable if the OS folder open request fails. + } + }, []) + // Load preset themes on mount useEffect(() => { const loadThemes = async () => { @@ -353,6 +379,85 @@ export default function AppearanceSettingsPage() { + {/* Pet companion */} + + + + {petEnabled && pets.map((pet) => ( + +
+ +
+
+
{pet.displayName}
+
{pet.description}
+
+
+ } + action={ + selectedPetId === pet.id ? ( + + {t("settings.appearance.petSelected")} + + ) : ( + + ) + } + /> + ))} + {petEnabled && ( + +
{t("settings.appearance.petCustom")}
+
+ {t("settings.appearance.petCustomHint")} +
+
+ } + description={petsFolder ?? '~/.qwen/pets'} + action={ +
+ + +
+ } + /> + )} + + + {/* Tool Icons — shows the command → icon mapping used in turn cards */} + + , +); diff --git a/apps/electron/src/renderer/pet.html b/apps/electron/src/renderer/pet.html new file mode 100644 index 000000000..4683c2ed0 --- /dev/null +++ b/apps/electron/src/renderer/pet.html @@ -0,0 +1,17 @@ + + + + + + + Qwen Pet + + + +
+ + + diff --git a/apps/electron/src/renderer/pets/pet-animation.test.ts b/apps/electron/src/renderer/pets/pet-animation.test.ts new file mode 100644 index 000000000..b7f21b6f2 --- /dev/null +++ b/apps/electron/src/renderer/pets/pet-animation.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test'; +import { + PET_BACKGROUND_SIZE, + PET_STATE_FRAMES, + PET_STATES, + backgroundPositionFor, + buildSequence, +} from './pet-animation'; + +describe('pet-animation frame tables', () => { + it('covers all nine states', () => { + expect(PET_STATES).toHaveLength(9); + for (const state of PET_STATES) { + expect(PET_STATE_FRAMES[state].length).toBeGreaterThan(0); + } + }); + + it('matches the atlas row/frame contract', () => { + const counts = Object.fromEntries( + PET_STATES.map((s) => [s, PET_STATE_FRAMES[s].length]), + ); + expect(counts).toEqual({ + idle: 6, + 'running-right': 8, + 'running-left': 8, + waving: 4, + jumping: 5, + failed: 8, + waiting: 6, + running: 6, + review: 6, + }); + }); + + it('places each row on its own atlas row, columns ascending', () => { + PET_STATES.forEach((state, rowIndex) => { + PET_STATE_FRAMES[state].forEach((frame, col) => { + expect(frame.rowIndex).toBe(rowIndex); + expect(frame.columnIndex).toBe(col); + expect(frame.durationMs).toBeGreaterThan(0); + }); + }); + }); +}); + +describe('backgroundPositionFor', () => { + it('maps the first cell to the top-left', () => { + expect(backgroundPositionFor({ rowIndex: 0, columnIndex: 0, durationMs: 1 })).toBe( + '0% 0%', + ); + }); + + it('maps the last cell to the bottom-right', () => { + expect(backgroundPositionFor({ rowIndex: 8, columnIndex: 7, durationMs: 1 })).toBe( + '100% 100%', + ); + }); + + it('uses an 8x9 background-size', () => { + expect(PET_BACKGROUND_SIZE).toBe('800% 900%'); + }); +}); + +describe('buildSequence', () => { + it('renders a single static frame under reduced motion', () => { + const seq = buildSequence('running', true); + expect(seq.frames).toHaveLength(1); + expect(seq.loopStartIndex).toBeNull(); + }); + + it('loops idle forever', () => { + const seq = buildSequence('idle', false); + expect(seq.loopStartIndex).toBe(0); + expect(seq.frames.length).toBe(PET_STATE_FRAMES.idle.length); + }); + + it('plays an action three times then settles into idle', () => { + const seq = buildSequence('running', false); + const action = PET_STATE_FRAMES.running.length; // 6 + const idle = PET_STATE_FRAMES.idle.length; // 6 + expect(seq.frames).toHaveLength(action * 3 + idle); + expect(seq.loopStartIndex).toBe(action * 3); + // the loop tail is the idle row + expect(seq.frames[seq.loopStartIndex!].rowIndex).toBe(0); + }); +}); diff --git a/apps/electron/src/renderer/pets/pet-animation.ts b/apps/electron/src/renderer/pets/pet-animation.ts new file mode 100644 index 000000000..a0da781ba --- /dev/null +++ b/apps/electron/src/renderer/pets/pet-animation.ts @@ -0,0 +1,129 @@ +/** + * Pet sprite-atlas animation (clean-room). + * + * Atlas: 1536x1872, 8 cols x 9 rows, 192x208 px cells, transparent. + * Each row is one animation state. Frames are stepped by mutating a single + * element's `background-position`; `background-size` is set so the sheet maps + * cell-to-cell at percentage positions. + */ + +export type PetState = + | 'idle' + | 'running-right' + | 'running-left' + | 'waving' + | 'jumping' + | 'failed' + | 'waiting' + | 'running' + | 'review'; + +export const PET_STATES: readonly PetState[] = [ + 'idle', + 'running-right', + 'running-left', + 'waving', + 'jumping', + 'failed', + 'waiting', + 'running', + 'review', +]; + +export const PET_COLUMNS = 8; +export const PET_ROWS = 9; +export const PET_CELL_WIDTH = 192; +export const PET_CELL_HEIGHT = 208; + +export interface PetFrame { + rowIndex: number; + columnIndex: number; + durationMs: number; +} + +/** Build a row's frames: `count` frames, last one held a little longer. */ +function buildRow( + rowIndex: number, + count: number, + normalMs: number, + lastMs: number, +): PetFrame[] { + return Array.from({ length: count }, (_, i) => ({ + rowIndex, + columnIndex: i, + durationMs: i === count - 1 ? lastMs : normalMs, + })); +} + +// The idle row has hand-tuned per-frame timing (breathe + blink). +const IDLE_FRAMES: PetFrame[] = [ + { rowIndex: 0, columnIndex: 0, durationMs: 280 }, + { rowIndex: 0, columnIndex: 1, durationMs: 110 }, + { rowIndex: 0, columnIndex: 2, durationMs: 110 }, + { rowIndex: 0, columnIndex: 3, durationMs: 140 }, + { rowIndex: 0, columnIndex: 4, durationMs: 140 }, + { rowIndex: 0, columnIndex: 5, durationMs: 320 }, +]; + +// When the pet settles back to idle it loops slowly and calmly. +const IDLE_SLOWDOWN = 6; +const IDLE_SETTLED: PetFrame[] = IDLE_FRAMES.map((f) => ({ + ...f, + durationMs: f.durationMs * IDLE_SLOWDOWN, +})); + +export const PET_STATE_FRAMES: Record = { + idle: IDLE_FRAMES, + 'running-right': buildRow(1, 8, 120, 220), + 'running-left': buildRow(2, 8, 120, 220), + waving: buildRow(3, 4, 140, 280), + jumping: buildRow(4, 5, 140, 280), + failed: buildRow(5, 8, 140, 240), + waiting: buildRow(6, 6, 150, 260), + running: buildRow(7, 6, 120, 220), + review: buildRow(8, 6, 150, 280), +}; + +export interface PetSequence { + frames: PetFrame[]; + /** When the last frame is reached, jump back here; null = stop. */ + loopStartIndex: number | null; +} + +// Non-idle states play through a few times, then settle into the idle loop. +const ACTION_REPEATS = 3; + +/** + * Resolve the frame sequence to play for a state. + * - reduced motion: a single static frame, no loop. + * - idle: the slow idle loop, forever. + * - any action: play it `ACTION_REPEATS` times, then loop the idle settle. + */ +export function buildSequence( + state: PetState, + reducedMotion: boolean, +): PetSequence { + const base = PET_STATE_FRAMES[state]; + if (reducedMotion) { + return { frames: [base[0]], loopStartIndex: null }; + } + if (state === 'idle') { + return { frames: IDLE_SETTLED, loopStartIndex: 0 }; + } + const action: PetFrame[] = []; + for (let i = 0; i < ACTION_REPEATS; i += 1) action.push(...base); + return { + frames: [...action, ...IDLE_SETTLED], + loopStartIndex: action.length, + }; +} + +/** CSS `background-position` for a frame (percentage-based sprite stepping). */ +export function backgroundPositionFor(frame: PetFrame): string { + const x = (frame.columnIndex / (PET_COLUMNS - 1)) * 100; + const y = (frame.rowIndex / (PET_ROWS - 1)) * 100; + return `${x}% ${y}%`; +} + +/** `background-size` that makes the atlas map one cell per element. */ +export const PET_BACKGROUND_SIZE = `${PET_COLUMNS * 100}% ${PET_ROWS * 100}%`; diff --git a/apps/electron/src/renderer/pets/pet-atoms.ts b/apps/electron/src/renderer/pets/pet-atoms.ts new file mode 100644 index 000000000..e5f5ddd06 --- /dev/null +++ b/apps/electron/src/renderer/pets/pet-atoms.ts @@ -0,0 +1,11 @@ +/** Shared pet-companion state so the picker and the floating overlay stay in sync. */ +import { atom } from 'jotai'; +import type { CustomPetEntry } from '@craft-agent/shared/config'; +import { DEFAULT_PET_ID } from './registry'; +import { DEFAULT_PET_SIZE } from './pet-size'; + +export const selectedPetIdAtom = atom(DEFAULT_PET_ID); +export const petEnabledAtom = atom(true); +export const petSettingsLoadedAtom = atom(false); +export const petSizeAtom = atom(DEFAULT_PET_SIZE); +export const customPetsAtom = atom([]); diff --git a/apps/electron/src/renderer/pets/pet-size.ts b/apps/electron/src/renderer/pets/pet-size.ts new file mode 100644 index 000000000..6154138cf --- /dev/null +++ b/apps/electron/src/renderer/pets/pet-size.ts @@ -0,0 +1,7 @@ +export const DEFAULT_PET_SIZE = 96; +export const MIN_PET_SIZE = 64; +export const MAX_PET_SIZE = 240; + +export function normalizePetSize(size: number): number { + return Math.round(Math.min(MAX_PET_SIZE, Math.max(MIN_PET_SIZE, size))); +} diff --git a/apps/electron/src/renderer/pets/registry.ts b/apps/electron/src/renderer/pets/registry.ts new file mode 100644 index 000000000..dd2c94aad --- /dev/null +++ b/apps/electron/src/renderer/pets/registry.ts @@ -0,0 +1,59 @@ +/** + * Built-in pet companions and custom-pet merging. + * + * Built-in spritesheets are bundled by Vite from `assets/pets/`. Custom pets + * come from the main process (`loadCustomPets`) as base64 data URLs. + */ +import type { CustomPetEntry } from '@craft-agent/shared/config'; +import qwenSpritesheet from '@/assets/pets/qwen-spritesheet.webp'; + +export interface PetDescriptor { + id: string; + displayName: string; + description: string; + /** URL or data URL usable directly as a CSS background-image. */ + spritesheetUrl: string; + custom?: boolean; +} + +export const DEFAULT_PET_ID = 'qwen'; + +export const BUILT_IN_PETS: PetDescriptor[] = [ + { + id: 'qwen', + displayName: 'Qwen', + description: 'Your Qwen capybara companion.', + spritesheetUrl: qwenSpritesheet, + }, +]; + +/** Built-in pets followed by any custom pets (custom ids cannot shadow built-ins). */ +export function mergeCustomPets( + custom: CustomPetEntry[] | undefined, +): PetDescriptor[] { + if (!custom || custom.length === 0) return BUILT_IN_PETS; + const builtinIds = new Set(BUILT_IN_PETS.map((p) => p.id)); + const extras = custom + .filter((c) => c.id && !builtinIds.has(c.id)) + .map((c) => ({ + id: c.id, + displayName: c.displayName || c.id, + description: c.description, + spritesheetUrl: c.spritesheetDataUrl, + custom: true, + })); + return [...BUILT_IN_PETS, ...extras]; +} + +/** Resolve a pet by id, falling back to the default then the first available. */ +export function resolvePet( + id: string | undefined, + pets: PetDescriptor[] = BUILT_IN_PETS, +): PetDescriptor { + return ( + pets.find((p) => p.id === id) ?? + pets.find((p) => p.id === DEFAULT_PET_ID) ?? + pets[0] ?? + BUILT_IN_PETS[0] + ); +} diff --git a/apps/electron/src/renderer/pets/usePetActivityState.ts b/apps/electron/src/renderer/pets/usePetActivityState.ts new file mode 100644 index 000000000..630a7160f --- /dev/null +++ b/apps/electron/src/renderer/pets/usePetActivityState.ts @@ -0,0 +1,101 @@ +/** + * Derives the pet animation state purely from the global agent event stream + * (`electronAPI.onSessionEvent`), so it works in any window — including the + * standalone desktop-pet window that has no access to the main window's Jotai + * session atoms. + * + * activity events (text/tool/status) -> running + * permission_request -> waiting + * complete -> jumping (brief) then idle + * error / interrupted -> failed (brief) then idle + */ +import { useEffect, useRef, useState } from 'react'; +import type { SessionEvent } from '@craft-agent/shared/protocol'; +import type { PetState } from './pet-animation'; + +const TRANSIENT_MS: Record<'jumping' | 'failed', number> = { + jumping: 1300, + failed: 2600, +}; + +// Safety net: if a turn streams activity but never emits a terminal event, +// fall back to idle after this much silence. +const ACTIVITY_TIMEOUT_MS = 10_000; + +const ACTIVITY_EVENTS = new Set([ + 'text_delta', + 'text_complete', + 'tool_result', + 'status', + 'task_progress', +]); + +export function usePetActivityState(): PetState { + const [processing, setProcessing] = useState(false); + const [awaiting, setAwaiting] = useState(false); + const [transient, setTransient] = useState(null); + + const transientTimer = useRef | null>(null); + const activityTimer = useRef | null>(null); + + useEffect(() => { + if (!window.electronAPI?.onSessionEvent) return; + + const flash = (next: 'jumping' | 'failed') => { + setTransient(next); + if (transientTimer.current) clearTimeout(transientTimer.current); + transientTimer.current = setTimeout( + () => setTransient(null), + TRANSIENT_MS[next], + ); + }; + + const stopActivity = () => { + setProcessing(false); + if (activityTimer.current) clearTimeout(activityTimer.current); + }; + + const markActivity = () => { + setAwaiting(false); + setProcessing(true); + if (activityTimer.current) clearTimeout(activityTimer.current); + activityTimer.current = setTimeout( + () => setProcessing(false), + ACTIVITY_TIMEOUT_MS, + ); + }; + + const cleanup = window.electronAPI.onSessionEvent((event: SessionEvent) => { + switch (event.type) { + case 'permission_request': + setAwaiting(true); + break; + case 'complete': + setAwaiting(false); + stopActivity(); + flash('jumping'); + break; + case 'error': + case 'interrupted': + setAwaiting(false); + stopActivity(); + flash('failed'); + break; + default: + if (ACTIVITY_EVENTS.has(event.type)) markActivity(); + break; + } + }); + + return () => { + cleanup(); + if (transientTimer.current) clearTimeout(transientTimer.current); + if (activityTimer.current) clearTimeout(activityTimer.current); + }; + }, []); + + if (transient) return transient; + if (awaiting) return 'waiting'; + if (processing) return 'running'; + return 'idle'; +} diff --git a/apps/electron/src/renderer/pets/usePetCompanion.ts b/apps/electron/src/renderer/pets/usePetCompanion.ts new file mode 100644 index 000000000..f2f63a294 --- /dev/null +++ b/apps/electron/src/renderer/pets/usePetCompanion.ts @@ -0,0 +1,126 @@ +/** + * Bridges the persisted pet settings + custom pets (main process) with shared + * Jotai atoms so the Appearance picker and the floating overlay stay in sync. + */ +import { useAtom } from 'jotai'; +import { useCallback, useEffect, useMemo } from 'react'; +import { + customPetsAtom, + petEnabledAtom, + petSettingsLoadedAtom, + petSizeAtom, + selectedPetIdAtom, +} from './pet-atoms'; +import { normalizePetSize } from './pet-size'; +import { mergeCustomPets, resolvePet, type PetDescriptor } from './registry'; + +// Load persisted state once per app session, regardless of how many components +// mount the hook. +let bootstrapStarted = false; + +export interface PetCompanion { + pets: PetDescriptor[]; + selectedPet: PetDescriptor; + selectedPetId: string; + setSelectedPetId: (id: string) => void; + petEnabled: boolean; + setPetEnabled: (enabled: boolean) => void; + petSettingsLoaded: boolean; + petSize: number; + setPetSize: (size: number) => void; + refreshCustomPets: () => Promise; +} + +export function usePetCompanion(): PetCompanion { + const [selectedPetId, setSelectedIdState] = useAtom(selectedPetIdAtom); + const [petEnabled, setEnabledState] = useAtom(petEnabledAtom); + const [petSettingsLoaded, setPetSettingsLoaded] = useAtom( + petSettingsLoadedAtom, + ); + const [petSize, setPetSizeState] = useAtom(petSizeAtom); + const [customPets, setCustomPets] = useAtom(customPetsAtom); + + const refreshCustomPets = useCallback(async () => { + const list = await window.electronAPI?.loadCustomPets?.(); + if (list) setCustomPets(list); + }, [setCustomPets]); + + useEffect(() => { + if (bootstrapStarted) return; + bootstrapStarted = true; + void (async () => { + try { + const [id, enabled, size, custom] = await Promise.all([ + window.electronAPI?.getSelectedPetId?.(), + window.electronAPI?.getPetEnabled?.(), + window.electronAPI?.getPetSize?.(), + window.electronAPI?.loadCustomPets?.(), + ]); + if (id) setSelectedIdState(id); + if (typeof enabled === 'boolean') setEnabledState(enabled); + if (typeof size === 'number') setPetSizeState(normalizePetSize(size)); + if (custom) setCustomPets(custom); + } catch { + // Fall back to in-memory defaults if the IPC layer isn't ready. + } finally { + setPetSettingsLoaded(true); + } + })(); + }, [ + setSelectedIdState, + setEnabledState, + setPetSettingsLoaded, + setPetSizeState, + setCustomPets, + ]); + + useEffect(() => { + return window.electronAPI?.onPetEnabledChanged?.((enabled) => { + setEnabledState(enabled); + }); + }, [setEnabledState]); + + const pets = useMemo(() => mergeCustomPets(customPets), [customPets]); + const selectedPet = useMemo( + () => resolvePet(selectedPetId, pets), + [selectedPetId, pets], + ); + + const setSelectedPetId = useCallback( + (id: string) => { + setSelectedIdState(id); + void window.electronAPI?.setSelectedPetId?.(id); + }, + [setSelectedIdState], + ); + + const setPetEnabled = useCallback( + (enabled: boolean) => { + setEnabledState(enabled); + void window.electronAPI?.setPetEnabled?.(enabled); + }, + [setEnabledState], + ); + + const setPetSize = useCallback( + (size: number) => { + const normalized = normalizePetSize(size); + setPetSizeState(normalized); + void window.electronAPI?.setPetSize?.(normalized); + }, + [setPetSizeState], + ); + + return { + pets, + selectedPet, + selectedPetId, + setSelectedPetId, + petEnabled, + setPetEnabled, + petSettingsLoaded, + petSize, + setPetSize, + refreshCustomPets, + }; +} diff --git a/apps/electron/src/renderer/pets/usePetNotifications.ts b/apps/electron/src/renderer/pets/usePetNotifications.ts new file mode 100644 index 000000000..9ad03913b --- /dev/null +++ b/apps/electron/src/renderer/pets/usePetNotifications.ts @@ -0,0 +1,103 @@ +/** + * Quick notification cards shown above the desktop pet — one card per session, + * updated in place as that session's state changes (running -> pending -> + * complete / error). Driven by the global agent event stream. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { SessionEvent } from '@craft-agent/shared/protocol'; + +export type PetNotificationKind = + | 'running' + | 'pending' + | 'success' + | 'error' + | 'info'; + +export interface PetNotification { + sessionId: string; + kind: PetNotificationKind; + /** i18n key for the card title. */ + titleKey: string; + /** monotonic update counter — newest on top. */ + seq: number; +} + +// Streaming/working signals that keep a session in the "running" state. +const ACTIVITY = new Set([ + 'text_delta', + 'text_complete', + 'tool_result', + 'status', + 'task_progress', +]); + +let seq = 0; + +export function usePetNotifications() { + const [bySession, setBySession] = useState>( + () => new Map(), + ); + + const dismiss = useCallback((sessionId: string) => { + setBySession((prev) => { + if (!prev.has(sessionId)) return prev; + const next = new Map(prev); + next.delete(sessionId); + return next; + }); + }, []); + + const clear = useCallback(() => setBySession(new Map()), []); + + useEffect(() => { + if (!window.electronAPI?.onSessionEvent) return; + + const setState = ( + sessionId: string, + kind: PetNotificationKind, + titleKey: string, + ) => { + setBySession((prev) => { + const cur = prev.get(sessionId); + if (cur && cur.kind === kind) return prev; // no transition -> no churn + seq += 1; + const next = new Map(prev); + next.set(sessionId, { sessionId, kind, titleKey, seq }); + return next; + }); + }; + + const cleanup = window.electronAPI.onSessionEvent((event: SessionEvent) => { + const sessionId = (event as { sessionId?: string }).sessionId; + if (!sessionId) return; + switch (event.type) { + case 'permission_request': + setState(sessionId, 'pending', 'pet.notify.approval'); + break; + case 'complete': + setState(sessionId, 'success', 'pet.notify.complete'); + break; + case 'error': + setState(sessionId, 'error', 'pet.notify.error'); + break; + case 'interrupted': + setState(sessionId, 'info', 'pet.notify.interrupted'); + break; + default: + if (ACTIVITY.has(event.type)) { + setState(sessionId, 'running', 'pet.notify.running'); + } + break; + } + }); + + return cleanup; + }, []); + + const items = useMemo( + () => Array.from(bySession.values()).sort((a, b) => b.seq - a.seq), + [bySession], + ); + + return { items, dismiss, clear }; +} diff --git a/apps/electron/src/shared/__tests__/ipc-channels.test.ts b/apps/electron/src/shared/__tests__/ipc-channels.test.ts index cc7382a86..2279d7bb6 100644 --- a/apps/electron/src/shared/__tests__/ipc-channels.test.ts +++ b/apps/electron/src/shared/__tests__/ipc-channels.test.ts @@ -24,8 +24,17 @@ const EXPECTED_CHANNELS: string[] = [ 'LLM_Connection:setDefault', 'LLM_Connection:setWorkspaceDefault', 'LLM_Connection:test', + 'appearance:getPetEnabled', + 'appearance:getPetSize', 'appearance:getRichToolDescriptions', + 'appearance:getSelectedPetId', + 'appearance:loadCustomPets', + 'appearance:openPetsFolder', + 'appearance:petEnabledChanged', + 'appearance:setPetEnabled', + 'appearance:setPetSize', 'appearance:setRichToolDescriptions', + 'appearance:setSelectedPetId', 'auth:logout', 'auth:showDeleteSessionConfirmation', 'auth:showLogoutConfirmation', @@ -205,6 +214,8 @@ const EXPECTED_CHANNELS: string[] = [ 'sessions:import', 'sessions:importRemoteTransfer', 'sessions:killShell', + 'sessions:listChanged', + 'sessions:listRefreshStateChanged', 'sessions:markAllRead', 'sessions:respondToCredential', 'sessions:respondToPermission', @@ -218,12 +229,21 @@ const EXPECTED_CHANNELS: string[] = [ 'settings:getDefaultThinkingLevel', 'settings:getGlobalPermissionMode', 'settings:getNetworkProxy', + 'settings:getQwenCoreSettings', + 'settings:getQwenPermissionSettings', 'settings:getServerConfig', 'settings:getServerStatus', 'settings:listQwenProviders', + 'settings:removeQwenHook', + 'settings:removeQwenMcpServer', 'settings:setDefaultThinkingLevel', 'settings:setGlobalPermissionMode', 'settings:setNetworkProxy', + 'settings:setQwenCoreSetting', + 'settings:setQwenExtensionSetting', + 'settings:setQwenHook', + 'settings:setQwenMcpServer', + 'settings:setQwenPermissionRules', 'settings:setServerConfig', 'settings:setupLlmConnection', 'settings:testLlmConnectionSetup', @@ -298,6 +318,9 @@ const EXPECTED_CHANNELS: string[] = [ 'window:moveDrag', 'window:openSessionInNewWindow', 'window:openWorkspace', + 'window:petFocusSession', + 'window:petSetEnabled', + 'window:petSetIgnoreMouse', 'window:setTrafficLights', 'window:switchWorkspace', 'workspace:getPermissions', @@ -307,6 +330,7 @@ const EXPECTED_CHANNELS: string[] = [ 'workspaceSettings:update', 'workspaces:checkSlug', 'workspaces:create', + 'workspaces:createPermanentWorktree', 'workspaces:get', 'workspaces:updateRemote', ]; @@ -352,4 +376,11 @@ describe('BroadcastEventMap payload shapes', () => { const _check: AssertTuple = true; expect(_check).toBe(true); }); + + it('appearance:petEnabledChanged carries enabled', () => { + type Payload = + BroadcastEventMap[typeof RPC_CHANNELS.appearance.PET_ENABLED_CHANGED]; + const _check: AssertTuple = true; + expect(_check).toBe(true); + }); }); diff --git a/apps/electron/src/shared/types.ts b/apps/electron/src/shared/types.ts index a552e1ba8..3eb0bdfdb 100644 --- a/apps/electron/src/shared/types.ts +++ b/apps/electron/src/shared/types.ts @@ -54,6 +54,7 @@ export type { // Auth types for onboarding import type { AuthState, SetupNeeds } from '@craft-agent/shared/auth/types'; import type { AuthType } from '@craft-agent/shared/config/types'; +import type { CustomPetEntry } from '@craft-agent/shared/config/pets'; export type { AuthState, SetupNeeds, AuthType }; // Credential health types @@ -424,6 +425,10 @@ export interface ElectronAPI { name: string, remoteServer?: { url: string; token: string; remoteWorkspaceId: string }, ): Promise; + createPermanentWorktree( + workspaceId: string, + branchName: string, + ): Promise; checkWorkspaceSlug(slug: string): Promise<{ exists: boolean; path: string }>; updateWorkspaceRemoteServer( workspaceId: string, @@ -841,6 +846,18 @@ export interface ElectronAPI { // Appearance settings getRichToolDescriptions(): Promise; setRichToolDescriptions(enabled: boolean): Promise; + getSelectedPetId(): Promise; + setSelectedPetId(id: string): Promise; + getPetEnabled(): Promise; + setPetEnabled(enabled: boolean): Promise; + onPetEnabledChanged(callback: (enabled: boolean) => void): () => void; + getPetSize(): Promise; + setPetSize(size: number): Promise; + loadCustomPets(): Promise; + openPetsFolder(): Promise; + setPetWindowEnabled(enabled: boolean): Promise; + petWindowSetIgnoreMouse(ignore: boolean): Promise; + petFocusSession(sessionId: string): Promise; // Prompt caching & context getExtendedPromptCache(): Promise; diff --git a/apps/electron/src/transport/channel-map.ts b/apps/electron/src/transport/channel-map.ts index 0615307d3..79a4c1f35 100644 --- a/apps/electron/src/transport/channel-map.ts +++ b/apps/electron/src/transport/channel-map.ts @@ -63,6 +63,7 @@ export const CHANNEL_MAP = { // Workspace management getWorkspaces: invoke(RPC_CHANNELS.workspaces.GET), createWorkspace: invoke(RPC_CHANNELS.workspaces.CREATE), + createPermanentWorktree: invoke(RPC_CHANNELS.workspaces.CREATE_PERMANENT_WORKTREE), checkWorkspaceSlug: invoke(RPC_CHANNELS.workspaces.CHECK_SLUG), updateWorkspaceRemoteServer: invoke(RPC_CHANNELS.workspaces.UPDATE_REMOTE), testRemoteConnection: invoke(RPC_CHANNELS.remote.TEST_CONNECTION), @@ -337,6 +338,18 @@ export const CHANNEL_MAP = { setRichToolDescriptions: invoke( RPC_CHANNELS.appearance.SET_RICH_TOOL_DESCRIPTIONS, ), + getSelectedPetId: invoke(RPC_CHANNELS.appearance.GET_SELECTED_PET_ID), + setSelectedPetId: invoke(RPC_CHANNELS.appearance.SET_SELECTED_PET_ID), + getPetEnabled: invoke(RPC_CHANNELS.appearance.GET_PET_ENABLED), + setPetEnabled: invoke(RPC_CHANNELS.appearance.SET_PET_ENABLED), + onPetEnabledChanged: listener(RPC_CHANNELS.appearance.PET_ENABLED_CHANGED), + getPetSize: invoke(RPC_CHANNELS.appearance.GET_PET_SIZE), + setPetSize: invoke(RPC_CHANNELS.appearance.SET_PET_SIZE), + loadCustomPets: invoke(RPC_CHANNELS.appearance.LOAD_CUSTOM_PETS), + openPetsFolder: invoke(RPC_CHANNELS.appearance.OPEN_PETS_FOLDER), + setPetWindowEnabled: invoke(RPC_CHANNELS.window.PET_SET_ENABLED), + petWindowSetIgnoreMouse: invoke(RPC_CHANNELS.window.PET_SET_IGNORE_MOUSE), + petFocusSession: invoke(RPC_CHANNELS.window.PET_FOCUS_SESSION), // Tools settings getBrowserToolEnabled: invoke(RPC_CHANNELS.tools.GET_BROWSER_TOOL_ENABLED), diff --git a/apps/electron/vite.config.ts b/apps/electron/vite.config.ts index 8e2566694..8ad5c9aab 100644 --- a/apps/electron/vite.config.ts +++ b/apps/electron/vite.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ playground: resolve(__dirname, 'src/renderer/playground.html'), 'browser-toolbar': resolve(__dirname, 'src/renderer/browser-toolbar.html'), 'browser-empty-state': resolve(__dirname, 'src/renderer/browser-empty-state.html'), + pet: resolve(__dirname, 'src/renderer/pet.html'), } } }, diff --git a/package.json b/package.json index 7162eb1c3..cbad1d43c 100644 --- a/package.json +++ b/package.json @@ -79,18 +79,19 @@ "electron:dev:menu": "bash scripts/electron-dev.sh", "electron:dev:logs": "pgrep -f 'tail -f.*@craft-agent/electron/main.log' > /dev/null || osascript -e \"tell application \\\"Terminal\\\" to do script \\\"$PWD/scripts/tail-electron-logs.sh\\\"\"", "electron:vendor:qwen": "bun run scripts/vendor-qwen-code.ts", + "electron:builder-config": "bun run scripts/electron-builder-config.ts", "sync-secrets": "bash scripts/sync-secrets.sh", "fresh-start": "bun run scripts/fresh-start.ts", "fresh-start:token": "bun run scripts/fresh-start.ts --token-only", "print:system-prompt": "bun run packages/shared/src/prompts/print-system-prompt.ts", "browser-tool": "bun run scripts/browser-tool.ts", - "electron:dist": "bun run electron:vendor:qwen && bun run electron:build && cd apps/electron && electron-builder --config electron-builder.yml", - "electron:dist:mac": "bun run electron:vendor:qwen && bun run electron:build && cd apps/electron && electron-builder --config electron-builder.yml --mac", - "electron:dist:win": "bun run electron:vendor:qwen && bun run electron:build && cd apps/electron && electron-builder --config electron-builder.yml --win", - "electron:dist:linux": "bun run electron:vendor:qwen && bun run electron:build && cd apps/electron && electron-builder --config electron-builder.yml --linux", - "electron:dist:dev:mac": "bun run electron:vendor:qwen && CSC_IDENTITY_AUTO_DISCOVERY=false CRAFT_DEV_RUNTIME=1 bun run electron:build && cd apps/electron && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml --mac", - "electron:dist:dev:win": "bun run electron:vendor:qwen && CRAFT_DEV_RUNTIME=1 bun run electron:build && cd apps/electron && electron-builder --config electron-builder.yml --win", - "electron:dist:dev:linux": "bun run electron:vendor:qwen && CRAFT_DEV_RUNTIME=1 bun run electron:build && cd apps/electron && electron-builder --config electron-builder.yml --linux", + "electron:dist": "bun run electron:vendor:qwen && bun run electron:build && bun run electron:builder-config && cd apps/electron && electron-builder --config electron-builder.generated.yml", + "electron:dist:mac": "bun run electron:vendor:qwen && bun run electron:build && bun run electron:builder-config && cd apps/electron && electron-builder --config electron-builder.generated.yml --mac", + "electron:dist:win": "bun run electron:vendor:qwen && bun run electron:build && bun run electron:builder-config && cd apps/electron && electron-builder --config electron-builder.generated.yml --win", + "electron:dist:linux": "bun run electron:vendor:qwen && bun run electron:build && bun run electron:builder-config && cd apps/electron && electron-builder --config electron-builder.generated.yml --linux", + "electron:dist:dev:mac": "bun run electron:vendor:qwen && CSC_IDENTITY_AUTO_DISCOVERY=false CRAFT_DEV_RUNTIME=1 bun run electron:build && bun run electron:builder-config && cd apps/electron && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.generated.yml --mac", + "electron:dist:dev:win": "bun run electron:vendor:qwen && CRAFT_DEV_RUNTIME=1 bun run electron:build && bun run electron:builder-config && cd apps/electron && electron-builder --config electron-builder.generated.yml --win", + "electron:dist:dev:linux": "bun run electron:vendor:qwen && CRAFT_DEV_RUNTIME=1 bun run electron:build && bun run electron:builder-config && cd apps/electron && electron-builder --config electron-builder.generated.yml --linux", "playground:dev": "bun run scripts/dev-vite.ts --config apps/electron/vite.config.ts --default-port 5173 --port-env CRAFT_VITE_PORT --label playground --open /playground.html", "viewer:dev": "bun run scripts/dev-vite.ts --config apps/viewer/vite.config.ts --default-port 5174 --port-env CRAFT_VIEWER_PORT --label viewer --open /s/tz5-13I84pwK_he", "viewer:typecheck": "cd apps/viewer && bun run typecheck", diff --git a/packages/server-core/src/handlers/rpc/settings.ts b/packages/server-core/src/handlers/rpc/settings.ts index e00304740..eb082d334 100644 --- a/packages/server-core/src/handlers/rpc/settings.ts +++ b/packages/server-core/src/handlers/rpc/settings.ts @@ -63,6 +63,7 @@ import type { } from '@craft-agent/server-core/transport'; import type { HandlerDeps } from '../handler-deps'; import { + pushTyped, requestClientOpenFileDialog, requestClientOpenPath, } from '@craft-agent/server-core/transport'; @@ -86,6 +87,14 @@ export const HANDLED_CHANNELS = [ RPC_CHANNELS.power.GET_KEEP_AWAKE, RPC_CHANNELS.appearance.GET_RICH_TOOL_DESCRIPTIONS, RPC_CHANNELS.appearance.SET_RICH_TOOL_DESCRIPTIONS, + RPC_CHANNELS.appearance.GET_SELECTED_PET_ID, + RPC_CHANNELS.appearance.SET_SELECTED_PET_ID, + RPC_CHANNELS.appearance.GET_PET_ENABLED, + RPC_CHANNELS.appearance.SET_PET_ENABLED, + RPC_CHANNELS.appearance.GET_PET_SIZE, + RPC_CHANNELS.appearance.SET_PET_SIZE, + RPC_CHANNELS.appearance.LOAD_CUSTOM_PETS, + RPC_CHANNELS.appearance.OPEN_PETS_FOLDER, RPC_CHANNELS.caching.GET_EXTENDED_PROMPT_CACHE, RPC_CHANNELS.caching.SET_EXTENDED_PROMPT_CACHE, RPC_CHANNELS.caching.GET_ENABLE_1M_CONTEXT, @@ -704,6 +713,87 @@ export function registerSettingsHandlers( }, ); + // Get selected pet id + server.handle(RPC_CHANNELS.appearance.GET_SELECTED_PET_ID, async () => { + const { getSelectedPetId } = await import( + '@craft-agent/shared/config/storage' + ); + return getSelectedPetId(); + }); + + // Set selected pet id + server.handle( + RPC_CHANNELS.appearance.SET_SELECTED_PET_ID, + async (_ctx, id: string) => { + const { setSelectedPetId } = await import( + '@craft-agent/shared/config/storage' + ); + setSelectedPetId(id); + }, + ); + + // Get pet enabled setting + server.handle(RPC_CHANNELS.appearance.GET_PET_ENABLED, async () => { + const { getPetEnabled } = await import( + '@craft-agent/shared/config/storage' + ); + return getPetEnabled(); + }); + + // Set pet enabled setting + server.handle( + RPC_CHANNELS.appearance.SET_PET_ENABLED, + async (_ctx, enabled: boolean) => { + const { setPetEnabled } = await import( + '@craft-agent/shared/config/storage' + ); + setPetEnabled(enabled); + pushTyped( + server, + RPC_CHANNELS.appearance.PET_ENABLED_CHANGED, + { to: 'all' }, + enabled, + ); + }, + ); + + // Load custom pets from ${CONFIG_DIR}/pets + server.handle(RPC_CHANNELS.appearance.LOAD_CUSTOM_PETS, async () => { + const { loadCustomPets } = await import( + '@craft-agent/shared/config/pets' + ); + return loadCustomPets(); + }); + + // Get rendered pet size + server.handle(RPC_CHANNELS.appearance.GET_PET_SIZE, async () => { + const { getPetSize } = await import( + '@craft-agent/shared/config/storage' + ); + return getPetSize(); + }); + + // Set rendered pet size + server.handle( + RPC_CHANNELS.appearance.SET_PET_SIZE, + async (_ctx, size: number) => { + const { setPetSize } = await import( + '@craft-agent/shared/config/storage' + ); + setPetSize(size); + }, + ); + + // Ensure and open the custom pets folder on the local machine. + server.handle(RPC_CHANNELS.appearance.OPEN_PETS_FOLDER, async (ctx) => { + const { getPetsDir } = await import('@craft-agent/shared/config/pets'); + const petsDir = getPetsDir(); + mkdirSync(petsDir, { recursive: true }); + const result = await requestClientOpenPath(server, ctx.clientId, petsDir); + if (result.error) throw new Error(result.error); + return petsDir; + }); + // ============================================================ // Prompt Caching Settings // ============================================================ diff --git a/packages/server-core/src/handlers/rpc/workspace.ts b/packages/server-core/src/handlers/rpc/workspace.ts index 6a7e87ec3..07cf9ebb9 100644 --- a/packages/server-core/src/handlers/rpc/workspace.ts +++ b/packages/server-core/src/handlers/rpc/workspace.ts @@ -1,16 +1,79 @@ +import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { join } from 'path' +import { mkdir, writeFile } from 'node:fs/promises' import { homedir } from 'os' +import { dirname, join } from 'path' +import { promisify } from 'node:util' import { RPC_CHANNELS } from '@craft-agent/shared/protocol' import { getWorkspaceByNameOrId, addWorkspace, setActiveWorkspace, updateWorkspaceRemoteServer } from '@craft-agent/shared/config' +import { loadWorkspaceConfig } from '@craft-agent/shared/workspaces' import { perf } from '@craft-agent/shared/utils' import { pushTyped, type RpcServer } from '@craft-agent/server-core/transport' import type { HandlerDeps } from '../handler-deps' import { isValidWorkspaceRootPath } from '../../utils/path-validation' +const execFileAsync = promisify(execFile) +const WORKTREES_DIR = 'worktrees' + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + timeout: 30_000, + }) + return String(stdout).trim() +} + +function getGitErrorMessage(error: unknown): string { + if (error && typeof error === 'object') { + const err = error as { stderr?: unknown; message?: unknown } + if (typeof err.stderr === 'string' && err.stderr.trim()) { + return err.stderr.trim().split('\n')[0]! + } + if (typeof err.message === 'string' && err.message.trim()) { + return err.message.trim() + } + } + return String(error) +} + +function getWorktreeDirName(branchName: string): string { + return branchName + .replace(/[\\/]+/g, '--') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[.-]+|[.-]+$/g, '') || 'worktree' +} + +async function localBranchExists(repoRoot: string, branchName: string): Promise { + try { + await git(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]) + return true + } catch (error) { + if (error && typeof error === 'object' && (error as { code?: unknown }).code === 1) { + return false + } + throw error + } +} + +async function ensureWorktreesGitignored(repoRoot: string): Promise { + const qwenDir = join(repoRoot, '.qwen') + await mkdir(qwenDir, { recursive: true }) + await writeFile( + join(qwenDir, '.gitignore'), + `# Auto-generated by qwen-code.\n${WORKTREES_DIR}/\n`, + { encoding: 'utf8', flag: 'wx' }, + ).catch((error: unknown) => { + if (error && typeof error === 'object' && (error as { code?: unknown }).code === 'EEXIST') return + throw error + }) +} + export const CORE_HANDLED_CHANNELS = [ RPC_CHANNELS.workspaces.GET, RPC_CHANNELS.workspaces.CREATE, + RPC_CHANNELS.workspaces.CREATE_PERMANENT_WORKTREE, RPC_CHANNELS.workspaces.CHECK_SLUG, RPC_CHANNELS.workspaces.UPDATE_REMOTE, RPC_CHANNELS.window.GET_WORKSPACE, @@ -39,9 +102,7 @@ export function registerWorkspaceCoreHandlers(server: RpcServer, deps: HandlerDe const windowManager = deps.windowManager // Get workspaces (LOCAL_ONLY — includes rootPath for local Electron renderer) - server.handle(RPC_CHANNELS.workspaces.GET, async () => { - return sessionManager.getWorkspaces() - }) + server.handle(RPC_CHANNELS.workspaces.GET, async () => sessionManager.getWorkspaces()) // Create/open a project workspace. App-owned metadata may be relocated to managed storage. server.handle(RPC_CHANNELS.workspaces.CREATE, async (_ctx, folderPath: string, name: string, remoteServer?: { url: string; token: string; remoteWorkspaceId: string }) => { @@ -58,6 +119,73 @@ export function registerWorkspaceCoreHandlers(server: RpcServer, deps: HandlerDe return workspace }) + server.handle(RPC_CHANNELS.workspaces.CREATE_PERMANENT_WORKTREE, async (_ctx, workspaceId: string, branchNameInput: string) => { + const branchName = branchNameInput.trim() + if (!branchName) { + throw new Error('Branch name is required.') + } + if (branchName.startsWith('-')) { + throw new Error('Branch name must not start with "-".') + } + + const sourceWorkspace = getWorkspaceByNameOrId(workspaceId) + if (!sourceWorkspace) { + throw new Error('Workspace not found.') + } + if (sourceWorkspace.remoteServer) { + throw new Error('Cannot create a local git worktree from a remote workspace.') + } + + const workspaceConfig = loadWorkspaceConfig(sourceWorkspace.rootPath) + const sourceDir = workspaceConfig?.defaults?.workingDirectory || sourceWorkspace.rootPath + const validation = isValidWorkspaceRootPath(sourceDir) + if (!validation.valid) { + throw new Error(validation.reason!) + } + if (/[\\/]\.qwen[\\/]worktrees[\\/]/.test(sourceDir)) { + throw new Error('Already inside a git worktree. Create the new worktree from the main checkout.') + } + + let repoRoot: string + try { + repoRoot = await git(sourceDir, ['rev-parse', '--show-toplevel']) + } catch { + throw new Error(`Cannot create a worktree: ${sourceDir} is not a git repository.`) + } + + try { + await git(repoRoot, ['check-ref-format', `refs/heads/${branchName}`]) + } catch (error) { + throw new Error(`Invalid branch name: ${getGitErrorMessage(error)}`) + } + + if (await localBranchExists(repoRoot, branchName)) { + throw new Error(`Branch ${branchName} already exists. Choose a different branch name.`) + } + + const worktreesDir = join(repoRoot, '.qwen', WORKTREES_DIR) + const worktreePath = join(worktreesDir, getWorktreeDirName(branchName)) + if (existsSync(worktreePath)) { + throw new Error(`Worktree already exists at ${worktreePath}`) + } + + await ensureWorktreesGitignored(repoRoot).catch((error: unknown) => { + deps.platform.logger.warn(`Failed to update .qwen/.gitignore for worktrees: ${error}`) + }) + await mkdir(dirname(worktreePath), { recursive: true }) + + try { + await git(repoRoot, ['worktree', 'add', '-b', branchName, worktreePath, 'HEAD']) + } catch (error) { + throw new Error(`Failed to create worktree: ${getGitErrorMessage(error)}`) + } + + const workspace = addWorkspace({ name: branchName, rootPath: worktreePath }) + setActiveWorkspace(workspace.id) + deps.platform.logger.info(`Created permanent worktree workspace "${branchName}" at ${worktreePath}`) + return workspace + }) + // Check if a workspace slug already exists (for validation before creation) server.handle(RPC_CHANNELS.workspaces.CHECK_SLUG, async (_ctx, slug: string) => { const defaultWorkspacesDir = join(homedir(), '.craft-agent', 'workspaces') @@ -87,9 +215,7 @@ export function registerWorkspaceCoreHandlers(server: RpcServer, deps: HandlerDe }) // Get mode for the calling window (always 'main' now) - server.handle(RPC_CHANNELS.window.GET_MODE, () => { - return 'main' - }) + server.handle(RPC_CHANNELS.window.GET_MODE, () => 'main') // Switch workspace in current window (in-window switching) server.handle(RPC_CHANNELS.window.SWITCH_WORKSPACE, async (ctx, workspaceId: string) => { @@ -207,7 +333,7 @@ export function registerWorkspaceCoreHandlers(server: RpcServer, deps: HandlerDe const workspace = getWorkspaceByNameOrId(workspaceId) if (!workspace) throw new Error('Workspace not found') - const { writeFileSync, existsSync, unlinkSync, readdirSync } = await import('fs') + const { writeFileSync, unlinkSync, readdirSync } = await import('fs') const { join, normalize, basename } = await import('path') // Security: validate path @@ -357,7 +483,7 @@ export function registerWorkspaceCoreHandlers(server: RpcServer, deps: HandlerDe }) // Save views (replaces full array) - server.handle(RPC_CHANNELS.views.SAVE, async (_ctx, workspaceId: string, views: import('@craft-agent/shared/views').ViewConfig[]) => { + server.handle(RPC_CHANNELS.views.SAVE, async (_ctx, workspaceId: string, views: Array) => { const workspace = getWorkspaceByNameOrId(workspaceId) if (!workspace) throw new Error('Workspace not found') diff --git a/packages/shared/src/branding.ts b/packages/shared/src/branding.ts index 5f32dfd9f..6704a1a16 100644 --- a/packages/shared/src/branding.ts +++ b/packages/shared/src/branding.ts @@ -1,7 +1,7 @@ /** * Centralized branding configuration. * - * Supports multiple brand presets (e.g. "qwen-code", "modelstudio"). + * Supports multiple brand presets (e.g. "qwen-code", "openwork"). * Select at runtime via the CRAFT_BRAND environment variable. * Default: "qwen-code" (backward-compatible). */ @@ -29,8 +29,27 @@ export interface BrandConfig { selfReferName: string; /** Session viewer base URL */ viewerUrl: string; - /** Optional brand homepage shown in app menus */ - homepageUrl?: string; + /** Brand-owned external links shown in the Help menu */ + helpMenuLinks: Array<{ labelKey: string; url: string; icon: string }>; + /** Brand-specific Electron resource paths, relative to apps/electron/ */ + assets: { + /** Folder containing app icons and other brand-owned assets */ + resourceDir: string; + /** Renderer logo/symbol asset */ + rendererSymbol: string; + /** macOS app and DMG icon */ + macIcon: string; + /** Windows installer/app icon */ + winIcon: string; + /** Linux AppImage icon */ + linuxIcon: string; + /** Optional macOS development Dock icon PNG */ + devDockIcon?: string; + /** Optional SVG source icon for regeneration workflows */ + iconSvg?: string; + /** Optional macOS 26+ Liquid Glass compiled icon asset */ + liquidGlassAssetsCar?: string; + }; /** Multi-line credits text shown in the About panel */ credits: string; /** One-line credits summary */ @@ -53,6 +72,23 @@ const QWEN_CODE_BRAND: BrandConfig = { coAuthorLine: 'Co-Authored-By: Qwen Code ', selfReferName: 'Qwen Code', viewerUrl: 'https://agents.craft.do', + helpMenuLinks: [ + { + labelKey: 'menu.homepage', + url: 'https://qwen.ai/qwencode', + icon: 'House', + }, + ], + assets: { + resourceDir: 'resources/brands/qwen-code', + rendererSymbol: 'resources/brands/qwen-code/icon.svg', + macIcon: 'resources/brands/qwen-code/icon.icns', + winIcon: 'resources/brands/qwen-code/icon.ico', + linuxIcon: 'resources/brands/qwen-code/icon.png', + devDockIcon: 'resources/brands/qwen-code/dock.png', + iconSvg: 'resources/brands/qwen-code/icon.svg', + liquidGlassAssetsCar: 'resources/brands/qwen-code/Assets.car', + }, credits: '', creditsShort: '', creditsEntries: [], @@ -60,17 +96,32 @@ const QWEN_CODE_BRAND: BrandConfig = { const BRANDS: Record = { 'qwen-code': QWEN_CODE_BRAND, - modelstudio: { - id: 'modelstudio', - appName: 'ModelStudio Desktop', - appId: 'com.alibaba.modelstudio-desktop', - productName: 'ModelStudio Desktop', - artifactPrefix: 'ModelStudio-Desktop', + openwork: { + id: 'openwork', + appName: 'OpenWork', + appId: 'com.alibaba.openwork', + productName: 'OpenWork', + artifactPrefix: 'OpenWork', copyright: 'Copyright © 2026 Alibaba Group.', - coAuthorLine: 'Co-Authored-By: ModelStudio Desktop ', - selfReferName: 'ModelStudio Desktop', + coAuthorLine: 'Co-Authored-By: OpenWork ', + selfReferName: 'OpenWork', viewerUrl: 'https://agents.craft.do', - homepageUrl: 'https://github.com/modelstudioai', + helpMenuLinks: [ + { + labelKey: 'menu.homepage', + url: 'https://github.com/modelstudioai/openwork', + icon: 'House', + }, + ], + assets: { + resourceDir: 'resources/brands/openwork', + rendererSymbol: 'resources/brands/openwork/symbol.png', + macIcon: 'resources/brands/openwork/icon.icns', + winIcon: 'resources/brands/openwork/icon.png', + linuxIcon: 'resources/brands/openwork/icon.png', + devDockIcon: 'resources/brands/openwork/dock.png', + liquidGlassAssetsCar: 'resources/brands/openwork/Assets.car', + }, credits: 'Architecture: craft-agents-oss | Agent: Qwen Code', creditsShort: 'Based on craft-agents-oss & Qwen Code', creditsEntries: [ diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 6afb37cee..d9235488e 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -5,6 +5,7 @@ export * from './model-fetcher.ts'; export * from './preferences.ts'; export * from './qwen-settings.ts'; export * from './storage.ts'; +export * from './pets.ts'; export * from './theme.ts'; export * from './validators.ts'; export * from './cli-domains.ts'; diff --git a/packages/shared/src/config/pets.ts b/packages/shared/src/config/pets.ts new file mode 100644 index 000000000..9960ca7eb --- /dev/null +++ b/packages/shared/src/config/pets.ts @@ -0,0 +1,115 @@ +/** + * Custom pet companion loading. + * + * Users can drop their own animated pets into `~/.qwen/pets//`: + * + * ~/.qwen/pets// + * ├── pet.json { id, displayName, description, spritesheetPath } + * └── spritesheet.webp 1536x1872, 8 cols x 9 rows, 192x208 cells, transparent + * + * The spritesheet is returned as a base64 data URL so the renderer can use it + * directly as a CSS background-image without privileged file access. + */ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { extname, join, resolve, sep } from 'node:path'; + +/** On-disk manifest shape (`pet.json`). */ +export interface PetManifest { + id: string; + displayName: string; + description: string; + spritesheetPath?: string; +} + +/** A custom pet resolved for the renderer (spritesheet inlined as a data URL). */ +export interface CustomPetEntry { + id: string; + displayName: string; + description: string; + spritesheetDataUrl: string; +} + +/** Directory holding user-provided custom pets: ~/.qwen/pets (matches ~/.qwen/skills). */ +export function getPetsDir(): string { + return join(homedir(), '.qwen', 'pets'); +} + +// Refuse to inline absurdly large spritesheets (a well-formed atlas is < ~1MB). +const MAX_SPRITESHEET_BYTES = 12 * 1024 * 1024; + +function mimeFor(ext: string): string | undefined { + switch (ext.toLowerCase()) { + case '.webp': + return 'image/webp'; + case '.png': + return 'image/png'; + case '.gif': + return 'image/gif'; + default: + return undefined; + } +} + +/** + * Load every valid custom pet under `~/.qwen/pets/`. + * Malformed pets are skipped rather than throwing so one bad folder cannot + * break the picker. + */ +export function loadCustomPets(): CustomPetEntry[] { + const petsDir = getPetsDir(); + if (!existsSync(petsDir)) return []; + + let names: string[]; + try { + names = readdirSync(petsDir); + } catch { + return []; + } + + const entries: CustomPetEntry[] = []; + for (const name of names) { + if (name.startsWith('.')) continue; + try { + const dir = join(petsDir, name); + if (!statSync(dir).isDirectory()) continue; + + const manifestPath = join(dir, 'pet.json'); + if (!existsSync(manifestPath)) continue; + const manifest = JSON.parse( + readFileSync(manifestPath, 'utf-8'), + ) as Partial; + + const spritesheetRel = manifest.spritesheetPath || 'spritesheet.webp'; + // Guard against path traversal: the resolved spritesheet must stay inside + // the pet's own directory. + const dirResolved = resolve(dir); + const spritesheetPath = resolve(dir, spritesheetRel); + if ( + spritesheetPath !== dirResolved && + !spritesheetPath.startsWith(dirResolved + sep) + ) { + continue; + } + if (!existsSync(spritesheetPath)) continue; + + const mime = mimeFor(extname(spritesheetPath)); + if (!mime) continue; + if (statSync(spritesheetPath).size > MAX_SPRITESHEET_BYTES) continue; + + const base64 = readFileSync(spritesheetPath).toString('base64'); + const id = (manifest.id || name).trim(); + if (!id) continue; + entries.push({ + id, + displayName: (manifest.displayName || id).trim(), + description: (manifest.description || '').trim(), + spritesheetDataUrl: `data:${mime};base64,${base64}`, + }); + } catch { + // Skip malformed pet folder. + } + } + + return entries; +} diff --git a/packages/shared/src/config/storage.ts b/packages/shared/src/config/storage.ts index 6cbc1a0f8..bd8ba80a6 100644 --- a/packages/shared/src/config/storage.ts +++ b/packages/shared/src/config/storage.ts @@ -71,6 +71,11 @@ export interface StoredConfig { keepAwakeWhileRunning?: boolean; // Prevent screen sleep while sessions are running (default: false) // Tool metadata richToolDescriptions?: boolean; // Add intent/action metadata to all tool calls (default: true) + // Pet companion + selectedPetId?: string; // ID of the selected pet companion (default: 'qwen') + petEnabled?: boolean; // Show the floating pet companion (default: true) + petSize?: number; // Rendered height of the floating pet companion (default: 96) + petWindowBounds?: { x: number; y: number }; // Saved position of the floating pet window // Tools browserToolEnabled?: boolean; // Enable built-in browser tool (default: true). Disable for Playwright/Puppeteer. // Prompt caching & context @@ -535,6 +540,90 @@ export function setRichToolDescriptions(enabled: boolean): void { saveConfig(config); } +/** + * Get the selected pet companion id. Defaults to 'qwen' if not set. + */ +export function getSelectedPetId(): string { + const config = loadStoredConfig(); + return config?.selectedPetId ?? 'qwen'; +} + +/** + * Set the selected pet companion id. + */ +export function setSelectedPetId(id: string): void { + const config = loadStoredConfig(); + if (!config) return; + config.selectedPetId = id; + saveConfig(config); +} + +/** + * Get whether the floating pet companion is shown. Defaults to true. + */ +export function getPetEnabled(): boolean { + const config = loadStoredConfig(); + if (config?.petEnabled !== undefined) { + return config.petEnabled; + } + return true; +} + +/** + * Set whether the floating pet companion is shown. + */ +export function setPetEnabled(enabled: boolean): void { + const config = loadStoredConfig(); + if (!config) return; + config.petEnabled = enabled; + saveConfig(config); +} + +const DEFAULT_PET_SIZE = 96; +const MIN_PET_SIZE = 64; +const MAX_PET_SIZE = 240; + +function normalizePetSize(size: unknown): number { + if (typeof size !== 'number' || !Number.isFinite(size)) { + return DEFAULT_PET_SIZE; + } + return Math.round(Math.min(MAX_PET_SIZE, Math.max(MIN_PET_SIZE, size))); +} + +/** + * Get the rendered height of the floating pet companion. + */ +export function getPetSize(): number { + return normalizePetSize(loadStoredConfig()?.petSize); +} + +/** + * Persist the rendered height of the floating pet companion. + */ +export function setPetSize(size: number): void { + const config = loadStoredConfig(); + if (!config) return; + config.petSize = normalizePetSize(size); + saveConfig(config); +} + +/** + * Get the saved position of the floating pet window, if any. + */ +export function getPetWindowBounds(): { x: number; y: number } | undefined { + return loadStoredConfig()?.petWindowBounds; +} + +/** + * Persist the position of the floating pet window. + */ +export function setPetWindowBounds(bounds: { x: number; y: number }): void { + const config = loadStoredConfig(); + if (!config) return; + config.petWindowBounds = bounds; + saveConfig(config); +} + /** * Get whether extended prompt cache (1h TTL) is enabled. * When enabled, the interceptor upgrades cache_control TTL from 5m to 1h. diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index e466e2362..f4d02abc3 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -470,7 +470,7 @@ "menu.file": "Datei", "menu.forceReload": "Neu laden erzwingen", "menu.help": "Hilfe", - "menu.helpAndDocs": "Hilfe & Dokumentation", + "menu.homepage": "Startseite", "menu.hideCraftAgents": "{{appName}} ausblenden", "menu.installUpdate": "Update installieren", "menu.installUpdateVersion": "Update installieren (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "Modus", "settings.appearance.noToolIcons": "Keine Werkzeugsymbol-Zuordnungen gefunden", "settings.appearance.richToolDescriptions": "Ausführliche Werkzeugbeschreibungen", + "settings.appearance.pet": "Begleiter", + "settings.appearance.petDesc": "Ein Begleiter, der auf die Aktivität des Agents reagiert.", + "settings.appearance.petEnabled": "Begleiter anzeigen", + "settings.appearance.petEnabledDesc": "Zeigt einen animierten Begleiter in der Ecke des Fensters.", + "settings.appearance.petSelect": "Auswählen", + "settings.appearance.petSelected": "Ausgewählt", + "settings.appearance.petCustom": "Eigene Begleiter", + "settings.appearance.petCustomHint": "Füge einen Ordner mit pet.json und einem 8x9-Spritesheet hinzu und aktualisiere dann.", + "settings.appearance.petOpenFolder": "Ordner öffnen", + "settings.appearance.petRefresh": "Aktualisieren", + "pet.notify.running": "Läuft…", + "pet.notify.complete": "Aufgabe abgeschlossen", + "pet.notify.error": "Etwas ist schiefgelaufen", + "pet.notify.interrupted": "Unterbrochen", + "pet.notify.approval": "Wartet auf deine Freigabe", + "pet.notify.latest": "Neu", + "pet.menu.close": "Begleiter schließen", + "pet.notify.more": "{{n}} weitere", + "pet.notify.clear": "Alle löschen", "settings.appearance.richToolDescriptionsDesc": "Aktionsnamen und Absichtsbeschreibungen zu allen Werkzeugaufrufen hinzufügen. Bietet reichhaltigeren Aktivitätskontext in Sitzungen.", "settings.appearance.searchTools": "Werkzeuge suchen...", "settings.appearance.system": "System", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "Open the same memory files and folder used by /memory.", "settings.memory.pathUnavailable": "Path unavailable", "settings.memory.projectMemoryFile": "Project memory file", - "settings.memory.qwenSettingsFile": "Qwen settings file", + "settings.memory.qwenSettingsFile": "{{selfReferName}} settings file", "settings.memory.title": "Memory", "settings.memory.userMemoryFile": "User memory file", "settings.messaging.bindings.empty": "No active bindings", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "Permissions control how much autonomy your agent has. In Plan mode, the agent reads, researches, and prepares a plan before edits. Switch to YOLO only when you want every tool request approved automatically.", "settings.permissions.aboutText2": "A good workflow: start in Plan mode to investigate, review the proposed plan, then switch to Ask before edits, Edit automatically, or YOLO when you are ready.", "settings.permissions.allowedWritePath": "Erlaubter Schreibpfad", - "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to Qwen settings through ACP and apply to subsequent tool requests.", + "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to {{selfReferName}} settings through ACP and apply to subsequent tool requests.", "settings.permissions.cliAlignedIntro": "Manage {{appName}} permission policy for tool and command requests. Requests are evaluated in priority order: Deny, Ask, then Allow.", "settings.permissions.customApiEndpoint": "Benutzerdefinierter API-Endpunkt", "settings.permissions.customBashPattern": "Benutzerdefiniertes Bash-Muster", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "Keine Regeln zum Erlauben in diesem Bereich.", "settings.permissions.noRulesInScope.ask": "Keine Regeln zum Nachfragen in diesem Bereich.", "settings.permissions.noRulesInScope.deny": "Keine Regeln zum Blockieren in diesem Bereich.", - "settings.permissions.noSessionDesc": "Permission settings are read and written through Qwen ACP, so this page needs an active session in the workspace.", - "settings.permissions.noSessionTitle": "Open a Qwen session to edit permissions", + "settings.permissions.noSessionDesc": "Permission settings are read and written through {{selfReferName}} ACP, so this page needs an active session in the workspace.", + "settings.permissions.noSessionTitle": "Open a {{selfReferName}} session to edit permissions", "settings.permissions.quickGuideCommands": "Use ToolName(specifier) to restrict the rule to a specific operation, for example Bash(git status) or Bash(npm run build).", "settings.permissions.quickGuideScopes": "User rules apply across workspaces. Project rules apply only to this workspace and are merged with the user policy.", "settings.permissions.quickGuideTitle": "How to write a rule", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "Fuzzy file search", "settings.qwen.general.fuzzyFileSearchDesc": "Improve file matching for @ mentions and search.", "settings.qwen.general.outputLanguage": "Output language", - "settings.qwen.general.outputLanguageDesc": "Preferred language for Qwen responses.", + "settings.qwen.general.outputLanguageDesc": "Preferred language for {{selfReferName}} responses.", "settings.qwen.general.prAttribution": "PR attribution", "settings.qwen.general.prAttributionDesc": "Add {{appName}} attribution to pull request descriptions.", "settings.qwen.general.recapThreshold": "Recap threshold", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "Respect .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "Exclude files listed in .qwenignore.", "settings.qwen.general.responseLanguage": "Response Language", - "settings.qwen.general.responseLanguageDesc": "Control the language Qwen should prefer when answering.", + "settings.qwen.general.responseLanguageDesc": "Control the language {{selfReferName}} should prefer when answering.", "settings.qwen.general.sessionRecap": "Session recap", "settings.qwen.general.sessionRecapDesc": "Show a short recap when returning after being away.", "settings.qwen.general.toolApprovalMode": "Tool approval mode", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "Add Server", "settings.qwen.mcp.arguments": "Arguments", "settings.qwen.mcp.configuredServers": "Servers", - "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project Qwen settings.", + "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project {{selfReferName}} settings.", "settings.qwen.mcp.noServersDesc": "Add an HTTP, SSE, or stdio server.", "settings.qwen.mcp.noServersTitle": "No MCP servers configured", "settings.qwen.mcp.oneArgumentPerLine": "One argument per line.", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "Transport", "settings.qwen.mcp.trustThisServer": "Trust this server", "settings.qwen.mcp.trustThisServerDesc": "Skip confirmations for tools from this server.", - "settings.qwen.openSessionDesc": "These settings are read and written through Qwen ACP.", - "settings.qwen.openSessionTitle": "Open a Qwen session to edit settings", + "settings.qwen.openSessionDesc": "These settings are read and written through {{selfReferName}} ACP.", + "settings.qwen.openSessionTitle": "Open a {{selfReferName}} session to edit settings", "settings.qwen.option.auto": "Auto", "settings.qwen.refresh": "Refresh", "settings.qwen.scope.project": "Project", "settings.qwen.scope.user": "User", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP did not return settings.", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP did not return settings.", "settings.qwen.settingsUnavailableTitle": "Settings unavailable", "settings.server.allowRemoteConnections": "Verbindungen von anderen Geräten im Netzwerk erlauben.", "settings.server.certificate": "Zertifikat", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "Markierung konnte nicht gespeichert werden", "toast.couldNotUpdateHighlight": "Markierung konnte nicht aktualisiert werden", "toast.createdWorkspace": "Workspace „{{name}}“ erstellt", + "toast.createdWorktreeWorkspace": "Created worktree project \"{{name}}\"", "toast.deletedSkill": "Skill gelöscht: {{slug}}", "toast.deletedSource": "Quelle gelöscht", "toast.disconnected": "Verbindung getrennt — zum Wiederverbinden klicken", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "Muster konnte nicht kopiert werden", "toast.failedToCreateBrowser": "Browserfenster konnte nicht erstellt werden", "toast.failedToCreateWorkspace": "Workspace konnte nicht erstellt werden", + "toast.failedToCreateWorktreeWorkspace": "Failed to create worktree project", "toast.failedToDeleteAutomation": "Automatisierung konnte nicht gelöscht werden", "toast.failedToDeleteSkill": "Skill konnte nicht gelöscht werden", "toast.failedToDeleteSource": "Quelle konnte nicht gelöscht werden", @@ -1465,6 +1486,8 @@ "webui.logOut": "Abmelden", "workspace.addWorkspace": "Workspace hinzufügen…", "workspace.addWorkspaceDesc": "Wo Ihre Ideen auf die Werkzeuge treffen, die sie verwirklichen.", + "workspace.branchNameLabel": "Branch name", + "workspace.branchNamePlaceholder": "Enter branch name", "workspace.chooseExistingFolder": "Vorhandenen Ordner wählen", "workspace.chooseExistingFolderDesc": "Beliebigen Ordner als Workspace verwenden.", "workspace.chooseLocation": "Speicherort wählen", @@ -1473,8 +1496,11 @@ "workspace.conversations": "Chats", "workspace.createNew": "Neu erstellen", "workspace.createNewDesc": "Mit einem leeren Workspace beginnen.", + "workspace.createPermanentWorktree": "Create permanent worktree", "workspace.createWorkspace": "Workspace erstellen", "workspace.createWorkspaceDesc": "Namen eingeben und Speicherort für den Workspace wählen.", + "workspace.createWorktreeDialogDescription": "Create a new Git worktree from HEAD, add it as a project, and keep it until you remove it.", + "workspace.createWorktreeDialogTitle": "Create worktree and save as project", "workspace.creating": "Wird erstellt...", "workspace.defaultConversation": "Chats", "workspace.defaultLocation": "Standardspeicherort", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index 73dad5a1c..738f059ce 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -470,7 +470,7 @@ "menu.file": "File", "menu.forceReload": "Force Reload", "menu.help": "Help", - "menu.helpAndDocs": "Help & Documentation", + "menu.homepage": "Homepage", "menu.hideCraftAgents": "Hide {{appName}}", "menu.installUpdate": "Install Update", "menu.installUpdateVersion": "Install Update (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "Mode", "settings.appearance.noToolIcons": "No tool icon mappings found", "settings.appearance.richToolDescriptions": "Rich tool descriptions", + "settings.appearance.pet": "Pet", + "settings.appearance.petDesc": "A companion that reacts to what the agent is doing.", + "settings.appearance.petEnabled": "Show pet companion", + "settings.appearance.petEnabledDesc": "Display an animated pet in the corner of the window.", + "settings.appearance.petSelect": "Select", + "settings.appearance.petSelected": "Selected", + "settings.appearance.petCustom": "Custom pets", + "settings.appearance.petCustomHint": "Add a pet folder with pet.json and an 8x9 spritesheet, then refresh.", + "settings.appearance.petOpenFolder": "Open Folder", + "settings.appearance.petRefresh": "Refresh", + "pet.notify.running": "Working…", + "pet.notify.complete": "Task complete", + "pet.notify.error": "Something went wrong", + "pet.notify.interrupted": "Interrupted", + "pet.notify.approval": "Waiting for your approval", + "pet.notify.latest": "Latest", + "pet.menu.close": "Close pet", + "pet.notify.more": "{{n}} more", + "pet.notify.clear": "Clear all", "settings.appearance.richToolDescriptionsDesc": "Add action names and intent descriptions to all tool calls. Provides richer activity context in sessions.", "settings.appearance.searchTools": "Search tools...", "settings.appearance.system": "System", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "Open the same memory files and folder used by /memory.", "settings.memory.pathUnavailable": "Path unavailable", "settings.memory.projectMemoryFile": "Project memory file", - "settings.memory.qwenSettingsFile": "Qwen settings file", + "settings.memory.qwenSettingsFile": "{{selfReferName}} settings file", "settings.memory.title": "Memory", "settings.memory.userMemoryFile": "User memory file", "settings.messaging.bindings.empty": "No active bindings", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "Permissions control how much autonomy your agent has. In Plan mode, the agent reads, researches, and prepares a plan before edits. Switch to YOLO only when you want every tool request approved automatically.", "settings.permissions.aboutText2": "A good workflow: start in Plan mode to investigate, review the proposed plan, then switch to Ask before edits, Edit automatically, or YOLO when you are ready.", "settings.permissions.allowedWritePath": "Allowed write path", - "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to Qwen settings through ACP and apply to subsequent tool requests.", + "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to {{selfReferName}} settings through ACP and apply to subsequent tool requests.", "settings.permissions.cliAlignedIntro": "Manage {{appName}} permission policy for tool and command requests. Requests are evaluated in priority order: Deny, Ask, then Allow.", "settings.permissions.customApiEndpoint": "Custom API endpoint", "settings.permissions.customBashPattern": "Custom bash pattern", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "No allow rules in this scope.", "settings.permissions.noRulesInScope.ask": "No ask rules in this scope.", "settings.permissions.noRulesInScope.deny": "No deny rules in this scope.", - "settings.permissions.noSessionDesc": "Permission settings are read and written through Qwen ACP, so this page needs an active session in the workspace.", - "settings.permissions.noSessionTitle": "Open a Qwen session to edit permissions", + "settings.permissions.noSessionDesc": "Permission settings are read and written through {{selfReferName}} ACP, so this page needs an active session in the workspace.", + "settings.permissions.noSessionTitle": "Open a {{selfReferName}} session to edit permissions", "settings.permissions.quickGuideCommands": "Use ToolName(specifier) to restrict the rule to a specific operation, for example Bash(git status) or Bash(npm run build).", "settings.permissions.quickGuideScopes": "User rules apply across workspaces. Project rules apply only to this workspace and are merged with the user policy.", "settings.permissions.quickGuideTitle": "How to write a rule", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "Fuzzy file search", "settings.qwen.general.fuzzyFileSearchDesc": "Improve file matching for @ mentions and search.", "settings.qwen.general.outputLanguage": "Output language", - "settings.qwen.general.outputLanguageDesc": "Preferred language for Qwen responses.", + "settings.qwen.general.outputLanguageDesc": "Preferred language for {{selfReferName}} responses.", "settings.qwen.general.prAttribution": "PR attribution", "settings.qwen.general.prAttributionDesc": "Add {{appName}} attribution to pull request descriptions.", "settings.qwen.general.recapThreshold": "Recap threshold", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "Respect .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "Exclude files listed in .qwenignore.", "settings.qwen.general.responseLanguage": "Response Language", - "settings.qwen.general.responseLanguageDesc": "Control the language Qwen should prefer when answering.", + "settings.qwen.general.responseLanguageDesc": "Control the language {{selfReferName}} should prefer when answering.", "settings.qwen.general.sessionRecap": "Session recap", "settings.qwen.general.sessionRecapDesc": "Show a short recap when returning after being away.", "settings.qwen.general.toolApprovalMode": "Tool approval mode", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "Add Server", "settings.qwen.mcp.arguments": "Arguments", "settings.qwen.mcp.configuredServers": "Servers", - "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project Qwen settings.", + "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project {{selfReferName}} settings.", "settings.qwen.mcp.noServersDesc": "Add an HTTP, SSE, or stdio server.", "settings.qwen.mcp.noServersTitle": "No MCP servers configured", "settings.qwen.mcp.oneArgumentPerLine": "One argument per line.", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "Transport", "settings.qwen.mcp.trustThisServer": "Trust this server", "settings.qwen.mcp.trustThisServerDesc": "Skip confirmations for tools from this server.", - "settings.qwen.openSessionDesc": "These settings are read and written through Qwen ACP.", - "settings.qwen.openSessionTitle": "Open a Qwen session to edit settings", + "settings.qwen.openSessionDesc": "These settings are read and written through {{selfReferName}} ACP.", + "settings.qwen.openSessionTitle": "Open a {{selfReferName}} session to edit settings", "settings.qwen.option.auto": "Auto", "settings.qwen.refresh": "Refresh", "settings.qwen.scope.project": "Project", "settings.qwen.scope.user": "User", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP did not return settings.", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP did not return settings.", "settings.qwen.settingsUnavailableTitle": "Settings unavailable", "settings.server.allowRemoteConnections": "Allow connections from other machines on the network.", "settings.server.certificate": "Certificate", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "Could not save highlight", "toast.couldNotUpdateHighlight": "Could not update highlight", "toast.createdWorkspace": "Created workspace \"{{name}}\"", + "toast.createdWorktreeWorkspace": "Created worktree project \"{{name}}\"", "toast.deletedSkill": "Deleted skill: {{slug}}", "toast.deletedSource": "Deleted source", "toast.disconnected": "Disconnected — click to reconnect", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "Failed to copy pattern", "toast.failedToCreateBrowser": "Failed to create browser window", "toast.failedToCreateWorkspace": "Failed to create workspace", + "toast.failedToCreateWorktreeWorkspace": "Failed to create worktree project", "toast.failedToDeleteAutomation": "Failed to delete automation", "toast.failedToDeleteSkill": "Failed to delete skill", "toast.failedToDeleteSource": "Failed to delete source", @@ -1465,6 +1486,8 @@ "webui.logOut": "Log Out", "workspace.addWorkspace": "Add Workspace…", "workspace.addWorkspaceDesc": "Where your ideas meet the tools to make them happen.", + "workspace.branchNameLabel": "Branch name", + "workspace.branchNamePlaceholder": "Enter branch name", "workspace.chooseExistingFolder": "Choose existing folder", "workspace.chooseExistingFolderDesc": "Choose any folder to use as workspace.", "workspace.chooseLocation": "Choose a location", @@ -1473,8 +1496,11 @@ "workspace.conversations": "Chats", "workspace.createNew": "Create new", "workspace.createNewDesc": "Start fresh with an empty workspace.", + "workspace.createPermanentWorktree": "Create permanent worktree", "workspace.createWorkspace": "Create workspace", "workspace.createWorkspaceDesc": "Enter a name and choose where to store your workspace.", + "workspace.createWorktreeDialogDescription": "Create a new Git worktree from HEAD, add it as a project, and keep it until you remove it.", + "workspace.createWorktreeDialogTitle": "Create worktree and save as project", "workspace.creating": "Creating...", "workspace.defaultConversation": "Chats", "workspace.defaultLocation": "Default location", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index 65de918b4..374876800 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -470,7 +470,7 @@ "menu.file": "Archivo", "menu.forceReload": "Forzar recarga", "menu.help": "Ayuda", - "menu.helpAndDocs": "Ayuda y documentación", + "menu.homepage": "Página principal", "menu.hideCraftAgents": "Ocultar {{appName}}", "menu.installUpdate": "Instalar actualización", "menu.installUpdateVersion": "Instalar actualización (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "Modo", "settings.appearance.noToolIcons": "No se encontraron asignaciones de iconos de herramientas", "settings.appearance.richToolDescriptions": "Descripciones detalladas de herramientas", + "settings.appearance.pet": "Mascota", + "settings.appearance.petDesc": "Un compañero que reacciona a lo que hace el agente.", + "settings.appearance.petEnabled": "Mostrar mascota", + "settings.appearance.petEnabledDesc": "Muestra una mascota animada en la esquina de la ventana.", + "settings.appearance.petSelect": "Seleccionar", + "settings.appearance.petSelected": "Seleccionada", + "settings.appearance.petCustom": "Mascotas personalizadas", + "settings.appearance.petCustomHint": "Añade una carpeta con pet.json y un spritesheet de 8x9, luego actualiza.", + "settings.appearance.petOpenFolder": "Abrir carpeta", + "settings.appearance.petRefresh": "Actualizar", + "pet.notify.running": "En curso…", + "pet.notify.complete": "Tarea completada", + "pet.notify.error": "Algo salió mal", + "pet.notify.interrupted": "Interrumpido", + "pet.notify.approval": "Esperando tu aprobación", + "pet.notify.latest": "Reciente", + "pet.menu.close": "Cerrar mascota", + "pet.notify.more": "{{n}} más", + "pet.notify.clear": "Borrar todo", "settings.appearance.richToolDescriptionsDesc": "Añade nombres de acción y descripciones de intención a todas las llamadas de herramienta. Proporciona un contexto de actividad más rico en las sesiones.", "settings.appearance.searchTools": "Buscar herramientas...", "settings.appearance.system": "Sistema", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "Open the same memory files and folder used by /memory.", "settings.memory.pathUnavailable": "Path unavailable", "settings.memory.projectMemoryFile": "Project memory file", - "settings.memory.qwenSettingsFile": "Qwen settings file", + "settings.memory.qwenSettingsFile": "{{selfReferName}} settings file", "settings.memory.title": "Memory", "settings.memory.userMemoryFile": "User memory file", "settings.messaging.bindings.empty": "No active bindings", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "Permissions control how much autonomy your agent has. In Plan mode, the agent reads, researches, and prepares a plan before edits. Switch to YOLO only when you want every tool request approved automatically.", "settings.permissions.aboutText2": "A good workflow: start in Plan mode to investigate, review the proposed plan, then switch to Ask before edits, Edit automatically, or YOLO when you are ready.", "settings.permissions.allowedWritePath": "Ruta de escritura permitida", - "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to Qwen settings through ACP and apply to subsequent tool requests.", + "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to {{selfReferName}} settings through ACP and apply to subsequent tool requests.", "settings.permissions.cliAlignedIntro": "Manage {{appName}} permission policy for tool and command requests. Requests are evaluated in priority order: Deny, Ask, then Allow.", "settings.permissions.customApiEndpoint": "Endpoint de API personalizado", "settings.permissions.customBashPattern": "Patrón bash personalizado", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "No hay reglas de permitir en este ámbito.", "settings.permissions.noRulesInScope.ask": "No hay reglas de preguntar en este ámbito.", "settings.permissions.noRulesInScope.deny": "No hay reglas de denegar en este ámbito.", - "settings.permissions.noSessionDesc": "Permission settings are read and written through Qwen ACP, so this page needs an active session in the workspace.", - "settings.permissions.noSessionTitle": "Open a Qwen session to edit permissions", + "settings.permissions.noSessionDesc": "Permission settings are read and written through {{selfReferName}} ACP, so this page needs an active session in the workspace.", + "settings.permissions.noSessionTitle": "Open a {{selfReferName}} session to edit permissions", "settings.permissions.quickGuideCommands": "Use ToolName(specifier) to restrict the rule to a specific operation, for example Bash(git status) or Bash(npm run build).", "settings.permissions.quickGuideScopes": "User rules apply across workspaces. Project rules apply only to this workspace and are merged with the user policy.", "settings.permissions.quickGuideTitle": "How to write a rule", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "Fuzzy file search", "settings.qwen.general.fuzzyFileSearchDesc": "Improve file matching for @ mentions and search.", "settings.qwen.general.outputLanguage": "Output language", - "settings.qwen.general.outputLanguageDesc": "Preferred language for Qwen responses.", + "settings.qwen.general.outputLanguageDesc": "Preferred language for {{selfReferName}} responses.", "settings.qwen.general.prAttribution": "PR attribution", "settings.qwen.general.prAttributionDesc": "Add {{appName}} attribution to pull request descriptions.", "settings.qwen.general.recapThreshold": "Recap threshold", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "Respect .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "Exclude files listed in .qwenignore.", "settings.qwen.general.responseLanguage": "Response Language", - "settings.qwen.general.responseLanguageDesc": "Control the language Qwen should prefer when answering.", + "settings.qwen.general.responseLanguageDesc": "Control the language {{selfReferName}} should prefer when answering.", "settings.qwen.general.sessionRecap": "Session recap", "settings.qwen.general.sessionRecapDesc": "Show a short recap when returning after being away.", "settings.qwen.general.toolApprovalMode": "Tool approval mode", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "Add Server", "settings.qwen.mcp.arguments": "Arguments", "settings.qwen.mcp.configuredServers": "Servers", - "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project Qwen settings.", + "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project {{selfReferName}} settings.", "settings.qwen.mcp.noServersDesc": "Add an HTTP, SSE, or stdio server.", "settings.qwen.mcp.noServersTitle": "No MCP servers configured", "settings.qwen.mcp.oneArgumentPerLine": "One argument per line.", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "Transport", "settings.qwen.mcp.trustThisServer": "Trust this server", "settings.qwen.mcp.trustThisServerDesc": "Skip confirmations for tools from this server.", - "settings.qwen.openSessionDesc": "These settings are read and written through Qwen ACP.", - "settings.qwen.openSessionTitle": "Open a Qwen session to edit settings", + "settings.qwen.openSessionDesc": "These settings are read and written through {{selfReferName}} ACP.", + "settings.qwen.openSessionTitle": "Open a {{selfReferName}} session to edit settings", "settings.qwen.option.auto": "Auto", "settings.qwen.refresh": "Refresh", "settings.qwen.scope.project": "Project", "settings.qwen.scope.user": "User", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP did not return settings.", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP did not return settings.", "settings.qwen.settingsUnavailableTitle": "Settings unavailable", "settings.server.allowRemoteConnections": "Permitir conexiones desde otras máquinas en la red.", "settings.server.certificate": "Certificado", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "No se pudo guardar el resaltado", "toast.couldNotUpdateHighlight": "No se pudo actualizar el resaltado", "toast.createdWorkspace": "Workspace «{{name}}» creado", + "toast.createdWorktreeWorkspace": "Created worktree project \"{{name}}\"", "toast.deletedSkill": "Skill eliminado: {{slug}}", "toast.deletedSource": "Fuente eliminada", "toast.disconnected": "Desconectado — haz clic para reconectar", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "No se pudo copiar el patrón", "toast.failedToCreateBrowser": "No se pudo crear la ventana del navegador", "toast.failedToCreateWorkspace": "No se pudo crear el Workspace", + "toast.failedToCreateWorktreeWorkspace": "Failed to create worktree project", "toast.failedToDeleteAutomation": "No se pudo eliminar la automatización", "toast.failedToDeleteSkill": "No se pudo eliminar el skill", "toast.failedToDeleteSource": "No se pudo eliminar la fuente", @@ -1465,6 +1486,8 @@ "webui.logOut": "Cerrar sesión", "workspace.addWorkspace": "Agregar Workspace…", "workspace.addWorkspaceDesc": "Donde tus ideas se encuentran con las herramientas para hacerlas realidad.", + "workspace.branchNameLabel": "Branch name", + "workspace.branchNamePlaceholder": "Enter branch name", "workspace.chooseExistingFolder": "Elegir carpeta existente", "workspace.chooseExistingFolderDesc": "Elige cualquier carpeta para usar como Workspace.", "workspace.chooseLocation": "Elegir una ubicación", @@ -1473,8 +1496,11 @@ "workspace.conversations": "Chats", "workspace.createNew": "Crear nuevo", "workspace.createNewDesc": "Empieza desde cero con un Workspace vacío.", + "workspace.createPermanentWorktree": "Create permanent worktree", "workspace.createWorkspace": "Crear Workspace", "workspace.createWorkspaceDesc": "Ingresa un nombre y elige dónde guardar tu Workspace.", + "workspace.createWorktreeDialogDescription": "Create a new Git worktree from HEAD, add it as a project, and keep it until you remove it.", + "workspace.createWorktreeDialogTitle": "Create worktree and save as project", "workspace.creating": "Creando...", "workspace.defaultConversation": "Chats", "workspace.defaultLocation": "Ubicación predeterminada", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index 820bd4f55..b004a93d5 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -470,7 +470,7 @@ "menu.file": "Fájl", "menu.forceReload": "Kényszerített újratöltés", "menu.help": "Súgó", - "menu.helpAndDocs": "Súgó és dokumentáció", + "menu.homepage": "Kezdőlap", "menu.hideCraftAgents": "{{appName}} elrejtése", "menu.installUpdate": "Frissítés telepítése", "menu.installUpdateVersion": "Frissítés telepítése (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "Mód", "settings.appearance.noToolIcons": "Nem található eszközikon-hozzárendelés", "settings.appearance.richToolDescriptions": "Részletes eszközleírások", + "settings.appearance.pet": "Kabala", + "settings.appearance.petDesc": "Egy társ, aki reagál arra, amit az ügynök csinál.", + "settings.appearance.petEnabled": "Kabala megjelenítése", + "settings.appearance.petEnabledDesc": "Animált kabalát jelenít meg az ablak sarkában.", + "settings.appearance.petSelect": "Kiválasztás", + "settings.appearance.petSelected": "Kiválasztva", + "settings.appearance.petCustom": "Egyéni kabalák", + "settings.appearance.petCustomHint": "Adj hozzá egy mappát pet.json fájllal és 8x9-es spritesheettel, majd frissíts.", + "settings.appearance.petOpenFolder": "Mappa megnyitása", + "settings.appearance.petRefresh": "Frissítés", + "pet.notify.running": "Folyamatban…", + "pet.notify.complete": "Feladat kész", + "pet.notify.error": "Valami elromlott", + "pet.notify.interrupted": "Megszakítva", + "pet.notify.approval": "Jóváhagyásra vár", + "pet.notify.latest": "Legújabb", + "pet.menu.close": "Kabala bezárása", + "pet.notify.more": "Még {{n}}", + "pet.notify.clear": "Összes törlése", "settings.appearance.richToolDescriptionsDesc": "Műveletek nevének és céljának hozzáadása minden eszközhíváshoz. Gazdagabb tevékenységi kontextust biztosít a munkamenetekben.", "settings.appearance.searchTools": "Eszközök keresése...", "settings.appearance.system": "Rendszer", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "Open the same memory files and folder used by /memory.", "settings.memory.pathUnavailable": "Path unavailable", "settings.memory.projectMemoryFile": "Project memory file", - "settings.memory.qwenSettingsFile": "Qwen settings file", + "settings.memory.qwenSettingsFile": "{{selfReferName}} settings file", "settings.memory.title": "Memory", "settings.memory.userMemoryFile": "User memory file", "settings.messaging.bindings.empty": "No active bindings", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "Permissions control how much autonomy your agent has. In Plan mode, the agent reads, researches, and prepares a plan before edits. Switch to YOLO only when you want every tool request approved automatically.", "settings.permissions.aboutText2": "A good workflow: start in Plan mode to investigate, review the proposed plan, then switch to Ask before edits, Edit automatically, or YOLO when you are ready.", "settings.permissions.allowedWritePath": "Engedélyezett írási útvonal", - "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to Qwen settings through ACP and apply to subsequent tool requests.", + "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to {{selfReferName}} settings through ACP and apply to subsequent tool requests.", "settings.permissions.cliAlignedIntro": "Manage {{appName}} permission policy for tool and command requests. Requests are evaluated in priority order: Deny, Ask, then Allow.", "settings.permissions.customApiEndpoint": "Egyéni API végpont", "settings.permissions.customBashPattern": "Egyéni bash minta", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "Nincsenek engedélyezési szabályok ebben a hatókörben.", "settings.permissions.noRulesInScope.ask": "Nincsenek rákérdezési szabályok ebben a hatókörben.", "settings.permissions.noRulesInScope.deny": "Nincsenek tiltási szabályok ebben a hatókörben.", - "settings.permissions.noSessionDesc": "Permission settings are read and written through Qwen ACP, so this page needs an active session in the workspace.", - "settings.permissions.noSessionTitle": "Open a Qwen session to edit permissions", + "settings.permissions.noSessionDesc": "Permission settings are read and written through {{selfReferName}} ACP, so this page needs an active session in the workspace.", + "settings.permissions.noSessionTitle": "Open a {{selfReferName}} session to edit permissions", "settings.permissions.quickGuideCommands": "Use ToolName(specifier) to restrict the rule to a specific operation, for example Bash(git status) or Bash(npm run build).", "settings.permissions.quickGuideScopes": "User rules apply across workspaces. Project rules apply only to this workspace and are merged with the user policy.", "settings.permissions.quickGuideTitle": "How to write a rule", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "Fuzzy file search", "settings.qwen.general.fuzzyFileSearchDesc": "Improve file matching for @ mentions and search.", "settings.qwen.general.outputLanguage": "Output language", - "settings.qwen.general.outputLanguageDesc": "Preferred language for Qwen responses.", + "settings.qwen.general.outputLanguageDesc": "Preferred language for {{selfReferName}} responses.", "settings.qwen.general.prAttribution": "PR attribution", "settings.qwen.general.prAttributionDesc": "Add {{appName}} attribution to pull request descriptions.", "settings.qwen.general.recapThreshold": "Recap threshold", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "Respect .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "Exclude files listed in .qwenignore.", "settings.qwen.general.responseLanguage": "Response Language", - "settings.qwen.general.responseLanguageDesc": "Control the language Qwen should prefer when answering.", + "settings.qwen.general.responseLanguageDesc": "Control the language {{selfReferName}} should prefer when answering.", "settings.qwen.general.sessionRecap": "Session recap", "settings.qwen.general.sessionRecapDesc": "Show a short recap when returning after being away.", "settings.qwen.general.toolApprovalMode": "Tool approval mode", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "Add Server", "settings.qwen.mcp.arguments": "Arguments", "settings.qwen.mcp.configuredServers": "Servers", - "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project Qwen settings.", + "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project {{selfReferName}} settings.", "settings.qwen.mcp.noServersDesc": "Add an HTTP, SSE, or stdio server.", "settings.qwen.mcp.noServersTitle": "No MCP servers configured", "settings.qwen.mcp.oneArgumentPerLine": "One argument per line.", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "Transport", "settings.qwen.mcp.trustThisServer": "Trust this server", "settings.qwen.mcp.trustThisServerDesc": "Skip confirmations for tools from this server.", - "settings.qwen.openSessionDesc": "These settings are read and written through Qwen ACP.", - "settings.qwen.openSessionTitle": "Open a Qwen session to edit settings", + "settings.qwen.openSessionDesc": "These settings are read and written through {{selfReferName}} ACP.", + "settings.qwen.openSessionTitle": "Open a {{selfReferName}} session to edit settings", "settings.qwen.option.auto": "Auto", "settings.qwen.refresh": "Refresh", "settings.qwen.scope.project": "Project", "settings.qwen.scope.user": "User", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP did not return settings.", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP did not return settings.", "settings.qwen.settingsUnavailableTitle": "Settings unavailable", "settings.server.allowRemoteConnections": "Kapcsolatok engedélyezése a hálózat más gépeiről.", "settings.server.certificate": "Tanúsítvány", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "Nem sikerült menteni a kiemelést", "toast.couldNotUpdateHighlight": "Nem sikerült frissíteni a kiemelést", "toast.createdWorkspace": "Létrehozott munkaterület: „{{name}}”", + "toast.createdWorktreeWorkspace": "Created worktree project \"{{name}}\"", "toast.deletedSkill": "Törölt készség: {{slug}}", "toast.deletedSource": "Forrás törölve", "toast.disconnected": "Kapcsolat bontva — kattints az újracsatlakozáshoz", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "Nem sikerült másolni a mintát", "toast.failedToCreateBrowser": "Nem sikerült létrehozni a böngészőablakot", "toast.failedToCreateWorkspace": "Nem sikerült létrehozni a munkaterületet", + "toast.failedToCreateWorktreeWorkspace": "Failed to create worktree project", "toast.failedToDeleteAutomation": "Nem sikerült törölni az automatizációt", "toast.failedToDeleteSkill": "Nem sikerült törölni a készséget", "toast.failedToDeleteSource": "Nem sikerült törölni a forrást", @@ -1465,6 +1486,8 @@ "webui.logOut": "Kijelentkezés", "workspace.addWorkspace": "Munkaterület hozzáadása…", "workspace.addWorkspaceDesc": "Ahol az ötleteid találkoznak a megvalósításhoz szükséges eszközökkel.", + "workspace.branchNameLabel": "Branch name", + "workspace.branchNamePlaceholder": "Enter branch name", "workspace.chooseExistingFolder": "Meglévő mappa kiválasztása", "workspace.chooseExistingFolderDesc": "Válassz bármely mappát munkaterületként.", "workspace.chooseLocation": "Válassz helyet", @@ -1473,8 +1496,11 @@ "workspace.conversations": "Csevegések", "workspace.createNew": "Új létrehozása", "workspace.createNewDesc": "Kezdj egy üres munkaterülettel.", + "workspace.createPermanentWorktree": "Create permanent worktree", "workspace.createWorkspace": "Munkaterület létrehozása", "workspace.createWorkspaceDesc": "Add meg a nevet, és válaszd ki, hova mentse a munkaterületet.", + "workspace.createWorktreeDialogDescription": "Create a new Git worktree from HEAD, add it as a project, and keep it until you remove it.", + "workspace.createWorktreeDialogTitle": "Create worktree and save as project", "workspace.creating": "Létrehozás...", "workspace.defaultConversation": "Csevegések", "workspace.defaultLocation": "Alapértelmezett hely", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index 29c1a3d97..75c7b3621 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -470,7 +470,7 @@ "menu.file": "ファイル", "menu.forceReload": "強制再読み込み", "menu.help": "ヘルプ", - "menu.helpAndDocs": "ヘルプとドキュメント", + "menu.homepage": "ホームページ", "menu.hideCraftAgents": "{{appName}}を隠す", "menu.installUpdate": "アップデートをインストール", "menu.installUpdateVersion": "アップデートをインストール (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "モード", "settings.appearance.noToolIcons": "ツールアイコンのマッピングが見つかりません", "settings.appearance.richToolDescriptions": "リッチなツール説明", + "settings.appearance.pet": "ペット", + "settings.appearance.petDesc": "エージェントの動きに反応するコンパニオン。", + "settings.appearance.petEnabled": "ペットを表示", + "settings.appearance.petEnabledDesc": "ウィンドウの隅にアニメーションするペットを表示します。", + "settings.appearance.petSelect": "選択", + "settings.appearance.petSelected": "選択中", + "settings.appearance.petCustom": "カスタムペット", + "settings.appearance.petCustomHint": "pet.json と 8x9 のスプライトシートを含むフォルダーを追加してから更新します。", + "settings.appearance.petOpenFolder": "フォルダーを開く", + "settings.appearance.petRefresh": "更新", + "pet.notify.running": "実行中…", + "pet.notify.complete": "タスク完了", + "pet.notify.error": "エラーが発生しました", + "pet.notify.interrupted": "中断されました", + "pet.notify.approval": "承認待ち", + "pet.notify.latest": "最新", + "pet.menu.close": "ペットを閉じる", + "pet.notify.more": "他 {{n}} 件", + "pet.notify.clear": "すべてクリア", "settings.appearance.richToolDescriptionsDesc": "すべてのツール呼び出しにアクション名と意図の説明を追加します。セッション内のアクティビティコンテキストをより豊かにします。", "settings.appearance.searchTools": "ツールを検索...", "settings.appearance.system": "システム", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "Open the same memory files and folder used by /memory.", "settings.memory.pathUnavailable": "Path unavailable", "settings.memory.projectMemoryFile": "Project memory file", - "settings.memory.qwenSettingsFile": "Qwen settings file", + "settings.memory.qwenSettingsFile": "{{selfReferName}} settings file", "settings.memory.title": "Memory", "settings.memory.userMemoryFile": "User memory file", "settings.messaging.bindings.empty": "No active bindings", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "Permissions control how much autonomy your agent has. In Plan mode, the agent reads, researches, and prepares a plan before edits. Switch to YOLO only when you want every tool request approved automatically.", "settings.permissions.aboutText2": "A good workflow: start in Plan mode to investigate, review the proposed plan, then switch to Ask before edits, Edit automatically, or YOLO when you are ready.", "settings.permissions.allowedWritePath": "書き込み許可パス", - "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to Qwen settings through ACP and apply to subsequent tool requests.", + "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to {{selfReferName}} settings through ACP and apply to subsequent tool requests.", "settings.permissions.cliAlignedIntro": "Manage {{appName}} permission policy for tool and command requests. Requests are evaluated in priority order: Deny, Ask, then Allow.", "settings.permissions.customApiEndpoint": "カスタムAPIエンドポイント", "settings.permissions.customBashPattern": "カスタムbashパターン", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "このスコープに許可ルールはありません。", "settings.permissions.noRulesInScope.ask": "このスコープに確認ルールはありません。", "settings.permissions.noRulesInScope.deny": "このスコープに拒否ルールはありません。", - "settings.permissions.noSessionDesc": "Permission settings are read and written through Qwen ACP, so this page needs an active session in the workspace.", - "settings.permissions.noSessionTitle": "Open a Qwen session to edit permissions", + "settings.permissions.noSessionDesc": "Permission settings are read and written through {{selfReferName}} ACP, so this page needs an active session in the workspace.", + "settings.permissions.noSessionTitle": "Open a {{selfReferName}} session to edit permissions", "settings.permissions.quickGuideCommands": "Use ToolName(specifier) to restrict the rule to a specific operation, for example Bash(git status) or Bash(npm run build).", "settings.permissions.quickGuideScopes": "User rules apply across workspaces. Project rules apply only to this workspace and are merged with the user policy.", "settings.permissions.quickGuideTitle": "How to write a rule", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "Fuzzy file search", "settings.qwen.general.fuzzyFileSearchDesc": "Improve file matching for @ mentions and search.", "settings.qwen.general.outputLanguage": "Output language", - "settings.qwen.general.outputLanguageDesc": "Preferred language for Qwen responses.", + "settings.qwen.general.outputLanguageDesc": "Preferred language for {{selfReferName}} responses.", "settings.qwen.general.prAttribution": "PR attribution", "settings.qwen.general.prAttributionDesc": "Add {{appName}} attribution to pull request descriptions.", "settings.qwen.general.recapThreshold": "Recap threshold", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "Respect .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "Exclude files listed in .qwenignore.", "settings.qwen.general.responseLanguage": "Response Language", - "settings.qwen.general.responseLanguageDesc": "Control the language Qwen should prefer when answering.", + "settings.qwen.general.responseLanguageDesc": "Control the language {{selfReferName}} should prefer when answering.", "settings.qwen.general.sessionRecap": "Session recap", "settings.qwen.general.sessionRecapDesc": "Show a short recap when returning after being away.", "settings.qwen.general.toolApprovalMode": "Tool approval mode", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "Add Server", "settings.qwen.mcp.arguments": "Arguments", "settings.qwen.mcp.configuredServers": "Servers", - "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project Qwen settings.", + "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project {{selfReferName}} settings.", "settings.qwen.mcp.noServersDesc": "Add an HTTP, SSE, or stdio server.", "settings.qwen.mcp.noServersTitle": "No MCP servers configured", "settings.qwen.mcp.oneArgumentPerLine": "One argument per line.", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "Transport", "settings.qwen.mcp.trustThisServer": "Trust this server", "settings.qwen.mcp.trustThisServerDesc": "Skip confirmations for tools from this server.", - "settings.qwen.openSessionDesc": "These settings are read and written through Qwen ACP.", - "settings.qwen.openSessionTitle": "Open a Qwen session to edit settings", + "settings.qwen.openSessionDesc": "These settings are read and written through {{selfReferName}} ACP.", + "settings.qwen.openSessionTitle": "Open a {{selfReferName}} session to edit settings", "settings.qwen.option.auto": "Auto", "settings.qwen.refresh": "Refresh", "settings.qwen.scope.project": "Project", "settings.qwen.scope.user": "User", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP did not return settings.", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP did not return settings.", "settings.qwen.settingsUnavailableTitle": "Settings unavailable", "settings.server.allowRemoteConnections": "ネットワーク上の他のマシンからの接続を許可します。", "settings.server.certificate": "証明書", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "ハイライトを保存できませんでした", "toast.couldNotUpdateHighlight": "ハイライトを更新できませんでした", "toast.createdWorkspace": "Workspace「{{name}}」を作成しました", + "toast.createdWorktreeWorkspace": "Created worktree project \"{{name}}\"", "toast.deletedSkill": "スキルを削除しました: {{slug}}", "toast.deletedSource": "ソースを削除しました", "toast.disconnected": "切断されました — クリックして再接続", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "パターンのコピーに失敗しました", "toast.failedToCreateBrowser": "ブラウザウィンドウの作成に失敗しました", "toast.failedToCreateWorkspace": "Workspaceの作成に失敗しました", + "toast.failedToCreateWorktreeWorkspace": "Failed to create worktree project", "toast.failedToDeleteAutomation": "オートメーションの削除に失敗しました", "toast.failedToDeleteSkill": "スキルの削除に失敗しました", "toast.failedToDeleteSource": "ソースの削除に失敗しました", @@ -1465,6 +1486,8 @@ "webui.logOut": "ログアウト", "workspace.addWorkspace": "Workspaceを追加…", "workspace.addWorkspaceDesc": "アイデアと実現するツールが出会う場所。", + "workspace.branchNameLabel": "Branch name", + "workspace.branchNamePlaceholder": "Enter branch name", "workspace.chooseExistingFolder": "既存のフォルダを選択", "workspace.chooseExistingFolderDesc": "任意のフォルダをWorkspaceとして選択します。", "workspace.chooseLocation": "場所を選択", @@ -1473,8 +1496,11 @@ "workspace.conversations": "チャット", "workspace.createNew": "新規作成", "workspace.createNewDesc": "空のWorkspaceから始めます。", + "workspace.createPermanentWorktree": "Create permanent worktree", "workspace.createWorkspace": "Workspaceを作成", "workspace.createWorkspaceDesc": "名前を入力し、Workspaceの保存場所を選択してください。", + "workspace.createWorktreeDialogDescription": "Create a new Git worktree from HEAD, add it as a project, and keep it until you remove it.", + "workspace.createWorktreeDialogTitle": "Create worktree and save as project", "workspace.creating": "作成中...", "workspace.defaultConversation": "チャット", "workspace.defaultLocation": "デフォルトの場所", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index 4bd49fe1c..b3730f066 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -470,7 +470,7 @@ "menu.file": "Plik", "menu.forceReload": "Wymuś przeładowanie", "menu.help": "Pomoc", - "menu.helpAndDocs": "Pomoc i dokumentacja", + "menu.homepage": "Strona główna", "menu.hideCraftAgents": "Ukryj {{appName}}", "menu.installUpdate": "Zainstaluj aktualizację", "menu.installUpdateVersion": "Zainstaluj aktualizację (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "Tryb", "settings.appearance.noToolIcons": "Nie znaleziono mapowań ikon narzędzi", "settings.appearance.richToolDescriptions": "Rozbudowane opisy narzędzi", + "settings.appearance.pet": "Maskotka", + "settings.appearance.petDesc": "Towarzysz reagujący na to, co robi agent.", + "settings.appearance.petEnabled": "Pokaż maskotkę", + "settings.appearance.petEnabledDesc": "Wyświetla animowaną maskotkę w rogu okna.", + "settings.appearance.petSelect": "Wybierz", + "settings.appearance.petSelected": "Wybrano", + "settings.appearance.petCustom": "Własne maskotki", + "settings.appearance.petCustomHint": "Dodaj folder z pet.json i arkuszem sprite'ów 8x9, a potem odśwież.", + "settings.appearance.petOpenFolder": "Otwórz folder", + "settings.appearance.petRefresh": "Odśwież", + "pet.notify.running": "W toku…", + "pet.notify.complete": "Zadanie ukończone", + "pet.notify.error": "Coś poszło nie tak", + "pet.notify.interrupted": "Przerwano", + "pet.notify.approval": "Czeka na Twoją zgodę", + "pet.notify.latest": "Najnowsze", + "pet.menu.close": "Zamknij maskotkę", + "pet.notify.more": "Jeszcze {{n}}", + "pet.notify.clear": "Wyczyść wszystko", "settings.appearance.richToolDescriptionsDesc": "Dodaj nazwy akcji i opisy intencji do wszystkich wywołań narzędzi. Zapewnia bogatszy kontekst aktywności w sesjach.", "settings.appearance.searchTools": "Szukaj narzędzi...", "settings.appearance.system": "System", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "Open the same memory files and folder used by /memory.", "settings.memory.pathUnavailable": "Path unavailable", "settings.memory.projectMemoryFile": "Project memory file", - "settings.memory.qwenSettingsFile": "Qwen settings file", + "settings.memory.qwenSettingsFile": "{{selfReferName}} settings file", "settings.memory.title": "Memory", "settings.memory.userMemoryFile": "User memory file", "settings.messaging.bindings.empty": "No active bindings", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "Permissions control how much autonomy your agent has. In Plan mode, the agent reads, researches, and prepares a plan before edits. Switch to YOLO only when you want every tool request approved automatically.", "settings.permissions.aboutText2": "A good workflow: start in Plan mode to investigate, review the proposed plan, then switch to Ask before edits, Edit automatically, or YOLO when you are ready.", "settings.permissions.allowedWritePath": "Dozwolona ścieżka zapisu", - "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to Qwen settings through ACP and apply to subsequent tool requests.", + "settings.permissions.cliAlignedFormat": "Rules may target an entire tool or a specific operation. Changes are persisted to {{selfReferName}} settings through ACP and apply to subsequent tool requests.", "settings.permissions.cliAlignedIntro": "Manage {{appName}} permission policy for tool and command requests. Requests are evaluated in priority order: Deny, Ask, then Allow.", "settings.permissions.customApiEndpoint": "Niestandardowy endpoint API", "settings.permissions.customBashPattern": "Niestandardowy wzorzec bash", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "Brak reguł zezwalania w tym zakresie.", "settings.permissions.noRulesInScope.ask": "Brak reguł pytania w tym zakresie.", "settings.permissions.noRulesInScope.deny": "Brak reguł odmowy w tym zakresie.", - "settings.permissions.noSessionDesc": "Permission settings are read and written through Qwen ACP, so this page needs an active session in the workspace.", - "settings.permissions.noSessionTitle": "Open a Qwen session to edit permissions", + "settings.permissions.noSessionDesc": "Permission settings are read and written through {{selfReferName}} ACP, so this page needs an active session in the workspace.", + "settings.permissions.noSessionTitle": "Open a {{selfReferName}} session to edit permissions", "settings.permissions.quickGuideCommands": "Use ToolName(specifier) to restrict the rule to a specific operation, for example Bash(git status) or Bash(npm run build).", "settings.permissions.quickGuideScopes": "User rules apply across workspaces. Project rules apply only to this workspace and are merged with the user policy.", "settings.permissions.quickGuideTitle": "How to write a rule", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "Fuzzy file search", "settings.qwen.general.fuzzyFileSearchDesc": "Improve file matching for @ mentions and search.", "settings.qwen.general.outputLanguage": "Output language", - "settings.qwen.general.outputLanguageDesc": "Preferred language for Qwen responses.", + "settings.qwen.general.outputLanguageDesc": "Preferred language for {{selfReferName}} responses.", "settings.qwen.general.prAttribution": "PR attribution", "settings.qwen.general.prAttributionDesc": "Add {{appName}} attribution to pull request descriptions.", "settings.qwen.general.recapThreshold": "Recap threshold", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "Respect .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "Exclude files listed in .qwenignore.", "settings.qwen.general.responseLanguage": "Response Language", - "settings.qwen.general.responseLanguageDesc": "Control the language Qwen should prefer when answering.", + "settings.qwen.general.responseLanguageDesc": "Control the language {{selfReferName}} should prefer when answering.", "settings.qwen.general.sessionRecap": "Session recap", "settings.qwen.general.sessionRecapDesc": "Show a short recap when returning after being away.", "settings.qwen.general.toolApprovalMode": "Tool approval mode", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "Add Server", "settings.qwen.mcp.arguments": "Arguments", "settings.qwen.mcp.configuredServers": "Servers", - "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project Qwen settings.", + "settings.qwen.mcp.configuredServersDesc": "Servers saved in User and Project {{selfReferName}} settings.", "settings.qwen.mcp.noServersDesc": "Add an HTTP, SSE, or stdio server.", "settings.qwen.mcp.noServersTitle": "No MCP servers configured", "settings.qwen.mcp.oneArgumentPerLine": "One argument per line.", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "Transport", "settings.qwen.mcp.trustThisServer": "Trust this server", "settings.qwen.mcp.trustThisServerDesc": "Skip confirmations for tools from this server.", - "settings.qwen.openSessionDesc": "These settings are read and written through Qwen ACP.", - "settings.qwen.openSessionTitle": "Open a Qwen session to edit settings", + "settings.qwen.openSessionDesc": "These settings are read and written through {{selfReferName}} ACP.", + "settings.qwen.openSessionTitle": "Open a {{selfReferName}} session to edit settings", "settings.qwen.option.auto": "Auto", "settings.qwen.refresh": "Refresh", "settings.qwen.scope.project": "Project", "settings.qwen.scope.user": "User", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP did not return settings.", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP did not return settings.", "settings.qwen.settingsUnavailableTitle": "Settings unavailable", "settings.server.allowRemoteConnections": "Zezwól na połączenia z innych urządzeń w sieci.", "settings.server.certificate": "Certyfikat", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "Nie udało się zapisać wyróżnienia", "toast.couldNotUpdateHighlight": "Nie udało się zaktualizować wyróżnienia", "toast.createdWorkspace": "Utworzono Workspace \"{{name}}\"", + "toast.createdWorktreeWorkspace": "Created worktree project \"{{name}}\"", "toast.deletedSkill": "Usunięto umiejętność: {{slug}}", "toast.deletedSource": "Usunięto źródło", "toast.disconnected": "Rozłączono — kliknij, aby ponownie połączyć", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "Nie udało się skopiować wzorca", "toast.failedToCreateBrowser": "Nie udało się utworzyć okna przeglądarki", "toast.failedToCreateWorkspace": "Nie udało się utworzyć Workspace", + "toast.failedToCreateWorktreeWorkspace": "Failed to create worktree project", "toast.failedToDeleteAutomation": "Nie udało się usunąć automatyzacji", "toast.failedToDeleteSkill": "Nie udało się usunąć umiejętności", "toast.failedToDeleteSource": "Nie udało się usunąć źródła", @@ -1465,6 +1486,8 @@ "webui.logOut": "Wyloguj się", "workspace.addWorkspace": "Dodaj Workspace…", "workspace.addWorkspaceDesc": "Gdzie twoje pomysły spotykają narzędzia, które je urzeczywistniają.", + "workspace.branchNameLabel": "Branch name", + "workspace.branchNamePlaceholder": "Enter branch name", "workspace.chooseExistingFolder": "Wybierz istniejący folder", "workspace.chooseExistingFolderDesc": "Wybierz dowolny folder do użycia jako Workspace.", "workspace.chooseLocation": "Wybierz lokalizację", @@ -1473,8 +1496,11 @@ "workspace.conversations": "Czaty", "workspace.createNew": "Utwórz nowy", "workspace.createNewDesc": "Zacznij od zera z pustym Workspace.", + "workspace.createPermanentWorktree": "Create permanent worktree", "workspace.createWorkspace": "Utwórz Workspace", "workspace.createWorkspaceDesc": "Podaj nazwę i wybierz miejsce przechowywania Workspace.", + "workspace.createWorktreeDialogDescription": "Create a new Git worktree from HEAD, add it as a project, and keep it until you remove it.", + "workspace.createWorktreeDialogTitle": "Create worktree and save as project", "workspace.creating": "Tworzenie...", "workspace.defaultConversation": "Czaty", "workspace.defaultLocation": "Domyślna lokalizacja", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index fc505f492..a460ccd4a 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -470,7 +470,7 @@ "menu.file": "文件", "menu.forceReload": "强制重新加载", "menu.help": "帮助", - "menu.helpAndDocs": "帮助和文档", + "menu.homepage": "主页", "menu.hideCraftAgents": "隐藏 {{appName}}", "menu.installUpdate": "安装更新", "menu.installUpdateVersion": "安装更新 (v{{version}})…", @@ -769,6 +769,25 @@ "settings.appearance.mode": "模式", "settings.appearance.noToolIcons": "未找到工具图标映射", "settings.appearance.richToolDescriptions": "丰富的工具描述", + "settings.appearance.pet": "宠物", + "settings.appearance.petDesc": "一个会根据 agent 当前状态做出反应的小伙伴。", + "settings.appearance.petEnabled": "显示宠物伙伴", + "settings.appearance.petEnabledDesc": "在窗口角落显示一个会动的宠物。", + "settings.appearance.petSelect": "选择", + "settings.appearance.petSelected": "已选", + "settings.appearance.petCustom": "自定义宠物", + "settings.appearance.petCustomHint": "每只宠物一个文件夹,包含 pet.json 和 8x9 spritesheet,放好后刷新。", + "settings.appearance.petOpenFolder": "打开文件夹", + "settings.appearance.petRefresh": "刷新", + "pet.notify.running": "处理中…", + "pet.notify.complete": "任务完成", + "pet.notify.error": "出错了", + "pet.notify.interrupted": "已中断", + "pet.notify.approval": "等待你的批准", + "pet.notify.latest": "最新", + "pet.menu.close": "关闭宠物", + "pet.notify.more": "另外 {{n}} 条", + "pet.notify.clear": "全部清除", "settings.appearance.richToolDescriptionsDesc": "为所有工具调用添加操作名称和意图描述。为会话提供更丰富的活动上下文。", "settings.appearance.searchTools": "搜索工具...", "settings.appearance.system": "系统", @@ -840,7 +859,7 @@ "settings.memory.managementDesc": "打开 /memory 使用的记忆文件和文件夹。", "settings.memory.pathUnavailable": "路径不可用", "settings.memory.projectMemoryFile": "项目记忆文件", - "settings.memory.qwenSettingsFile": "Qwen 设置文件", + "settings.memory.qwenSettingsFile": "{{selfReferName}} 设置文件", "settings.memory.title": "记忆", "settings.memory.userMemoryFile": "用户记忆文件", "settings.messaging.bindings.empty": "无活跃绑定", @@ -893,7 +912,7 @@ "settings.permissions.aboutText1": "权限控制智能体的自主程度。在 Plan 模式下,智能体会先阅读、调研并准备计划,然后才编辑。只有在希望自动批准所有工具请求时才切换到 YOLO。", "settings.permissions.aboutText2": "建议流程:先用 Plan 模式调研,审阅计划后,再按需要切换到 Ask before edits、Edit automatically 或 YOLO。", "settings.permissions.allowedWritePath": "允许的写入路径", - "settings.permissions.cliAlignedFormat": "规则可以作用于完整工具,也可以限定到具体操作。修改会通过 ACP 写入 Qwen 设置,并应用于后续工具请求。", + "settings.permissions.cliAlignedFormat": "规则可以作用于完整工具,也可以限定到具体操作。修改会通过 ACP 写入 {{selfReferName}} 设置,并应用于后续工具请求。", "settings.permissions.cliAlignedIntro": "管理 {{appName}} 对工具和命令请求的权限策略。请求会按“拒绝、询问、允许”的优先级依次判定。", "settings.permissions.customApiEndpoint": "自定义 API 端点", "settings.permissions.customBashPattern": "自定义 bash 模式", @@ -913,8 +932,8 @@ "settings.permissions.noRulesInScope.allow": "此范围内没有允许规则。", "settings.permissions.noRulesInScope.ask": "此范围内没有询问规则。", "settings.permissions.noRulesInScope.deny": "此范围内没有拒绝规则。", - "settings.permissions.noSessionDesc": "权限设置通过 Qwen ACP 读写,所以需要先在当前工作区打开一个 Qwen 会话。", - "settings.permissions.noSessionTitle": "打开一个 Qwen 会话后即可编辑权限", + "settings.permissions.noSessionDesc": "权限设置通过 {{selfReferName}} ACP 读写,所以需要先在当前工作区打开一个 {{selfReferName}} 会话。", + "settings.permissions.noSessionTitle": "打开一个 {{selfReferName}} 会话后即可编辑权限", "settings.permissions.quickGuideCommands": "使用 ToolName(specifier) 将规则限定到具体操作,例如 Bash(git status) 或 Bash(npm run build)。", "settings.permissions.quickGuideScopes": "用户规则适用于所有工作区;项目规则仅适用于当前工作区,并会与用户策略合并。", "settings.permissions.quickGuideTitle": "规则怎么写", @@ -997,7 +1016,7 @@ "settings.qwen.general.fuzzyFileSearch": "模糊文件搜索", "settings.qwen.general.fuzzyFileSearchDesc": "改进 @ 提及和搜索时的文件匹配。", "settings.qwen.general.outputLanguage": "输出语言", - "settings.qwen.general.outputLanguageDesc": "Qwen 回复时优先使用的语言。", + "settings.qwen.general.outputLanguageDesc": "{{selfReferName}} 回复时优先使用的语言。", "settings.qwen.general.prAttribution": "PR 署名", "settings.qwen.general.prAttributionDesc": "为 Pull Request 描述添加 {{appName}} 署名。", "settings.qwen.general.recapThreshold": "回顾阈值", @@ -1007,7 +1026,7 @@ "settings.qwen.general.respectQwenIgnore": "遵循 .qwenignore", "settings.qwen.general.respectQwenIgnoreDesc": "排除 .qwenignore 中列出的文件。", "settings.qwen.general.responseLanguage": "回复语言", - "settings.qwen.general.responseLanguageDesc": "控制 Qwen 回复时优先使用的语言。", + "settings.qwen.general.responseLanguageDesc": "控制 {{selfReferName}} 回复时优先使用的语言。", "settings.qwen.general.sessionRecap": "会话回顾", "settings.qwen.general.sessionRecapDesc": "离开一段时间后返回时显示简短回顾。", "settings.qwen.general.toolApprovalMode": "工具审批模式", @@ -1045,7 +1064,7 @@ "settings.qwen.mcp.addServer": "添加服务器", "settings.qwen.mcp.arguments": "参数", "settings.qwen.mcp.configuredServers": "服务器", - "settings.qwen.mcp.configuredServersDesc": "保存在用户和项目 Qwen 设置中的服务器。", + "settings.qwen.mcp.configuredServersDesc": "保存在用户和项目 {{selfReferName}} 设置中的服务器。", "settings.qwen.mcp.noServersDesc": "添加 HTTP、SSE 或 stdio 服务器。", "settings.qwen.mcp.noServersTitle": "未配置 MCP 服务器", "settings.qwen.mcp.oneArgumentPerLine": "每行一个参数。", @@ -1053,13 +1072,13 @@ "settings.qwen.mcp.transport": "传输方式", "settings.qwen.mcp.trustThisServer": "信任此服务器", "settings.qwen.mcp.trustThisServerDesc": "跳过来自此服务器工具的确认。", - "settings.qwen.openSessionDesc": "这些设置通过 Qwen ACP 读取和写入。", - "settings.qwen.openSessionTitle": "打开一个 Qwen 会话以编辑设置", + "settings.qwen.openSessionDesc": "这些设置通过 {{selfReferName}} ACP 读取和写入。", + "settings.qwen.openSessionTitle": "打开一个 {{selfReferName}} 会话以编辑设置", "settings.qwen.option.auto": "自动", "settings.qwen.refresh": "刷新", "settings.qwen.scope.project": "项目", "settings.qwen.scope.user": "用户", - "settings.qwen.settingsUnavailableDesc": "Qwen ACP 未返回设置。", + "settings.qwen.settingsUnavailableDesc": "{{selfReferName}} ACP 未返回设置。", "settings.qwen.settingsUnavailableTitle": "设置不可用", "settings.server.allowRemoteConnections": "允许网络上其他设备的连接。", "settings.server.certificate": "证书", @@ -1376,6 +1395,7 @@ "toast.couldNotSaveHighlight": "无法保存高亮", "toast.couldNotUpdateHighlight": "无法更新高亮", "toast.createdWorkspace": "已创建 Workspace \"{{name}}\"", + "toast.createdWorktreeWorkspace": "已创建工作树项目 \"{{name}}\"", "toast.deletedSkill": "已删除技能: {{slug}}", "toast.deletedSource": "已删除数据源", "toast.disconnected": "已断开——点击重新连接", @@ -1383,6 +1403,7 @@ "toast.failedToCopyPattern": "无法复制模式", "toast.failedToCreateBrowser": "无法创建浏览器窗口", "toast.failedToCreateWorkspace": "无法创建 Workspace", + "toast.failedToCreateWorktreeWorkspace": "无法创建工作树项目", "toast.failedToDeleteAutomation": "无法删除自动化", "toast.failedToDeleteSkill": "无法删除技能", "toast.failedToDeleteSource": "无法删除数据源", @@ -1465,6 +1486,8 @@ "webui.logOut": "退出登录", "workspace.addWorkspace": "添加 Workspace…", "workspace.addWorkspaceDesc": "让创意与实现工具相遇的地方。", + "workspace.branchNameLabel": "分支名称", + "workspace.branchNamePlaceholder": "输入分支名称", "workspace.chooseExistingFolder": "选择现有文件夹", "workspace.chooseExistingFolderDesc": "选择任意文件夹作为 Workspace。", "workspace.chooseLocation": "选择位置", @@ -1473,8 +1496,11 @@ "workspace.conversations": "对话", "workspace.createNew": "新建", "workspace.createNewDesc": "从空白 Workspace 开始。", + "workspace.createPermanentWorktree": "创建永久工作树", "workspace.createWorkspace": "创建 Workspace", "workspace.createWorkspaceDesc": "输入名称并选择 Workspace 存储位置。", + "workspace.createWorktreeDialogDescription": "从 HEAD 创建新的 Git 工作树,将其添加为项目,并保留到你将其移除为止", + "workspace.createWorktreeDialogTitle": "创建工作树并保存为项目", "workspace.creating": "正在创建...", "workspace.defaultConversation": "对话", "workspace.defaultLocation": "默认位置", diff --git a/packages/shared/src/i18n/setupI18n.ts b/packages/shared/src/i18n/setupI18n.ts index b09a34f7c..892c51c85 100644 --- a/packages/shared/src/i18n/setupI18n.ts +++ b/packages/shared/src/i18n/setupI18n.ts @@ -1,7 +1,7 @@ -import i18n, { type i18n as I18nInstance, type InitOptions } from "i18next"; -import { LOCALE_REGISTRY } from "./registry"; -import { SUPPORTED_LANGUAGE_CODES } from "./languages"; -import { BRAND } from "../branding.ts"; +import i18n, { type i18n as I18nInstance, type InitOptions } from 'i18next'; +import { LOCALE_REGISTRY } from './registry'; +import { SUPPORTED_LANGUAGE_CODES } from './languages'; +import { BRAND } from '../branding.ts'; // Build i18next resources from the locale registry. const resources = Object.fromEntries( @@ -33,17 +33,20 @@ export function setupI18n( instance.init({ resources, - fallbackLng: "en", + fallbackLng: 'en', supportedLngs: [...SUPPORTED_LANGUAGE_CODES], interpolation: { escapeValue: false, - defaultVariables: { appName: BRAND.appName }, + defaultVariables: { + appName: BRAND.appName, + selfReferName: BRAND.selfReferName, + }, }, initImmediate: false, // synchronous init — resources are bundled inline detection: { - order: ["localStorage", "navigator"], - caches: ["localStorage"], - lookupLocalStorage: "i18nextLng", + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'i18nextLng', }, } as InitOptions); diff --git a/packages/shared/src/protocol/channels.ts b/packages/shared/src/protocol/channels.ts index aa4c9f302..0c5e3f592 100644 --- a/packages/shared/src/protocol/channels.ts +++ b/packages/shared/src/protocol/channels.ts @@ -63,6 +63,7 @@ export const RPC_CHANNELS = { workspaces: { GET: 'workspaces:get', CREATE: 'workspaces:create', + CREATE_PERMANENT_WORKTREE: 'workspaces:createPermanentWorktree', CHECK_SLUG: 'workspaces:checkSlug', UPDATE_REMOTE: 'workspaces:updateRemote', }, @@ -82,6 +83,9 @@ export const RPC_CHANNELS = { END_DRAG: 'window:endDrag', FOCUS_STATE: 'window:focusState', GET_FOCUS_STATE: 'window:getFocusState', + PET_SET_ENABLED: 'window:petSetEnabled', + PET_SET_IGNORE_MOUSE: 'window:petSetIgnoreMouse', + PET_FOCUS_SESSION: 'window:petFocusSession', }, file: { READ: 'file:read', @@ -305,6 +309,15 @@ export const RPC_CHANNELS = { appearance: { GET_RICH_TOOL_DESCRIPTIONS: 'appearance:getRichToolDescriptions', SET_RICH_TOOL_DESCRIPTIONS: 'appearance:setRichToolDescriptions', + GET_SELECTED_PET_ID: 'appearance:getSelectedPetId', + SET_SELECTED_PET_ID: 'appearance:setSelectedPetId', + GET_PET_ENABLED: 'appearance:getPetEnabled', + SET_PET_ENABLED: 'appearance:setPetEnabled', + PET_ENABLED_CHANGED: 'appearance:petEnabledChanged', + GET_PET_SIZE: 'appearance:getPetSize', + SET_PET_SIZE: 'appearance:setPetSize', + LOAD_CUSTOM_PETS: 'appearance:loadCustomPets', + OPEN_PETS_FOLDER: 'appearance:openPetsFolder', }, tools: { GET_BROWSER_TOOL_ENABLED: 'tools:getBrowserToolEnabled', diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index 5065264f6..5a21eebb1 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -34,6 +34,7 @@ export interface BroadcastEventMap { [RPC_CHANNELS.skills.CHANGED]: [workspaceId: string, skills: LoadedSkill[]] [RPC_CHANNELS.llmConnections.CHANGED]: [] [RPC_CHANNELS.permissions.DEFAULTS_CHANGED]: [value: null] + [RPC_CHANNELS.appearance.PET_ENABLED_CHANGED]: [enabled: boolean] // Theme broadcasts (global) [RPC_CHANNELS.theme.APP_CHANGED]: [theme: ThemeOverrides | null] diff --git a/packages/shared/src/protocol/routing.ts b/packages/shared/src/protocol/routing.ts index 071300157..e33ac17de 100644 --- a/packages/shared/src/protocol/routing.ts +++ b/packages/shared/src/protocol/routing.ts @@ -25,6 +25,7 @@ export const LOCAL_ONLY_CHANNELS = new Set([ // workspaces — local workspace CRUD (workspace list is local config) RPC_CHANNELS.workspaces.GET, RPC_CHANNELS.workspaces.CREATE, + RPC_CHANNELS.workspaces.CREATE_PERMANENT_WORKTREE, RPC_CHANNELS.workspaces.CHECK_SLUG, RPC_CHANNELS.workspaces.UPDATE_REMOTE, @@ -44,6 +45,9 @@ export const LOCAL_ONLY_CHANNELS = new Set([ RPC_CHANNELS.window.END_DRAG, RPC_CHANNELS.window.FOCUS_STATE, RPC_CHANNELS.window.GET_FOCUS_STATE, + RPC_CHANNELS.window.PET_SET_ENABLED, + RPC_CHANNELS.window.PET_SET_IGNORE_MOUSE, + RPC_CHANNELS.window.PET_FOCUS_SESSION, // file — native file dialog RPC_CHANNELS.file.OPEN_DIALOG, @@ -156,6 +160,15 @@ export const LOCAL_ONLY_CHANNELS = new Set([ // appearance — local UI preferences RPC_CHANNELS.appearance.GET_RICH_TOOL_DESCRIPTIONS, RPC_CHANNELS.appearance.SET_RICH_TOOL_DESCRIPTIONS, + RPC_CHANNELS.appearance.GET_SELECTED_PET_ID, + RPC_CHANNELS.appearance.SET_SELECTED_PET_ID, + RPC_CHANNELS.appearance.GET_PET_ENABLED, + RPC_CHANNELS.appearance.SET_PET_ENABLED, + RPC_CHANNELS.appearance.PET_ENABLED_CHANGED, + RPC_CHANNELS.appearance.GET_PET_SIZE, + RPC_CHANNELS.appearance.SET_PET_SIZE, + RPC_CHANNELS.appearance.LOAD_CUSTOM_PETS, + RPC_CHANNELS.appearance.OPEN_PETS_FOLDER, // caching — prompt cache and context settings RPC_CHANNELS.caching.GET_EXTENDED_PROMPT_CACHE, diff --git a/scripts/electron-builder-config.ts b/scripts/electron-builder-config.ts new file mode 100644 index 000000000..35b3f8e62 --- /dev/null +++ b/scripts/electron-builder-config.ts @@ -0,0 +1,52 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import yaml from 'js-yaml'; + +import { BRAND } from '../packages/shared/src/branding.ts'; + +type MutableRecord = Record; + +function section(config: MutableRecord, key: string): MutableRecord { + const existing = config[key]; + if ( + existing && + typeof existing === 'object' && + !Array.isArray(existing) + ) { + return existing as MutableRecord; + } + + const next: MutableRecord = {}; + config[key] = next; + return next; +} + +const electronDir = join(process.cwd(), 'apps', 'electron'); +const inputPath = join(electronDir, 'electron-builder.yml'); +const outputPath = join(electronDir, 'electron-builder.generated.yml'); +const config = yaml.load(readFileSync(inputPath, 'utf8')) as MutableRecord; +const artifactName = `${BRAND.artifactPrefix}-\${arch}.\${ext}`; + +config.appId = BRAND.appId; +config.productName = BRAND.productName; +config.copyright = BRAND.copyright; + +const mac = section(config, 'mac'); +mac.icon = BRAND.assets.macIcon; +mac.artifactName = artifactName; + +const dmg = section(config, 'dmg'); +dmg.artifactName = `${BRAND.artifactPrefix}-\${arch}.dmg`; +dmg.icon = BRAND.assets.macIcon; +dmg.title = BRAND.productName; + +const win = section(config, 'win'); +win.icon = BRAND.assets.winIcon; +win.artifactName = artifactName; + +const linux = section(config, 'linux'); +linux.icon = BRAND.assets.linuxIcon; +linux.artifactName = artifactName; + +writeFileSync(outputPath, yaml.dump(config, { lineWidth: -1 })); +console.log(`Generated ${outputPath} for ${BRAND.id}`);