diff --git a/.github/scripts/upstream-release-trigger.sh b/.github/scripts/upstream-release-trigger.sh new file mode 100755 index 0000000000..9f99b32d72 --- /dev/null +++ b/.github/scripts/upstream-release-trigger.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# 上游 codeg 发版触发器:personal token 调 repository_dispatch 唤醒 fork 的 fork-release workflow。 +# cron 建议:每 6 小时一次(上游更新再频繁也足够及时)。 +# 用法:CODEG_DISPATCH_TOKEN=ghp_xxx ./upstream-release-trigger.sh +set -euo pipefail +: "${CODEG_DISPATCH_TOKEN:?need a personal access token with repo scope}" +curl -sS -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $CODEG_DISPATCH_TOKEN" \ + https://api.github.com/repos/unixcs/codeg/dispatches \ + -d '{"event_type":"upstream-release","client_payload":{}}' +echo "dispatch sent" diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000..541cefdfa5 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,192 @@ +name: Fork release (auto-sync upstream) + +# 当上游 spacering-net/codeg 发布新 release 时自动: +# 1. 在 CI 里把上游 tag 合并进 deploy/fork-updater(含 fork 更新源补丁+悬浮球) +# 2. 构建 linux-x64 服务端包(codeg-server + codeg-mcp + codeg-cli +# + codeg-computer-helper + web 静态资产),与官方 tarball 同构 +# 3. 用 fork 自己的 minisign 密钥签名(--app-version 绑定版本) +# 4. 生成 latest.json 并连同 tarball/.sig/.sha256 发到 fork 的同名 release +# tx/dell 上的 codeg 检查的是 fork 的 releases/latest,走它自带的一键更新。 +# +# 注意:合并结果**不回推** deploy 分支——GITHUB_TOKEN 无 workflows 权限, +# 合并若带入 .github/workflows 改动,push 必被拒。deploy 分支的源码由 +# 人工维护(上游合并有冲突时本地解决后 push),CI 每次都从上游 tag 现合。 +# 冲突时合并失败 → job 标红 + 开 issue 通知,需人工解决。 + +on: + repository_dispatch: + types: [upstream-release] + workflow_dispatch: + +permissions: + contents: write + issues: write + +concurrency: + group: fork-release + cancel-in-progress: false + +env: + ARTIFACT: codeg-server-linux-x64 + +jobs: + sync-and-release: + runs-on: ubuntu-22.04 + timeout-minutes: 90 + steps: + - name: Check out deploy branch + uses: actions/checkout@v4 + with: + ref: deploy/fork-updater + fetch-depth: 0 + token: ${{ secrets.FORK_SYNC_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Resolve upstream latest release + id: upstream + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + info=$(gh api repos/spacering-net/codeg/releases/latest --jq '{tag: .tag_name, ver: (.tag_name | ltrimstr("v")), notes: .body}') + echo "tag=$(echo "$info" | jq -r .tag)" >> "$GITHUB_OUTPUT" + echo "ver=$(echo "$info" | jq -r .ver)" >> "$GITHUB_OUTPUT" + echo "$info" | jq -r .notes > /tmp/upstream-notes.md + echo "Upstream: $(echo "$info" | jq -r .tag)" + + - name: Skip if fork already has this release + id: skip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if gh release view "${{ steps.upstream.outputs.tag }}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "Release ${{ steps.upstream.outputs.tag }} already exists on fork — nothing to do." + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi + + - name: Merge upstream tag (local only, not pushed back) + if: steps.skip.outputs.already == 'false' + id: merge + run: | + set -euo pipefail + git config user.name "fork-sync-bot" + git config user.email "bot@users.noreply.github.com" + git remote add upstream https://github.com/spacering-net/codeg.git + git fetch upstream "${{ steps.upstream.outputs.tag }}" + if git merge --no-edit FETCH_HEAD; then + echo "merged=true" >> "$GITHUB_OUTPUT" + git log --oneline -1 + else + git merge --abort || true + echo "merged=false" >> "$GITHUB_OUTPUT" + fi + + - name: Open issue on merge conflict + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "上游 ${{ steps.upstream.outputs.tag }} 合并冲突,需人工介入" \ + --body "spacering-net/codeg 发布了 ${{ steps.upstream.outputs.tag }},CI 合并进 deploy/fork-updater 时有冲突,本次未出包。请在本地:git fetch upstream && git merge ${{ steps.upstream.outputs.tag }},解决冲突(保留悬浮球与 fork 更新源补丁)后 push 到 deploy 分支,然后手动重跑 workflow。" + + - name: Install pnpm deps + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + uses: pnpm/action-setup@v4 + + - name: Set up Node + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + run: pnpm install --frozen-lockfile + + - name: Build frontend + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + run: pnpm build + + - name: Set up Rust + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Build server + companions (mirror official release.yml) + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + run: | + set -euo pipefail + cd src-tauri + cargo build --release --bin codeg-server --no-default-features --features server-bin + cargo build --release --bin codeg-mcp --no-default-features --features mcp-bin + cargo build --release --bin codeg-computer-helper --no-default-features --features computer-helper + cargo build --release --bin codeg-cli --no-default-features --features cli-bin + cd .. + mkdir -p dist/${ARTIFACT} + cp src-tauri/target/release/codeg-server dist/${ARTIFACT}/ + cp src-tauri/target/release/codeg-mcp dist/${ARTIFACT}/ + cp src-tauri/target/release/codeg-computer-helper dist/${ARTIFACT}/ + cp src-tauri/target/release/codeg-cli dist/${ARTIFACT}/ + chmod +x dist/${ARTIFACT}/* + cp -r out dist/${ARTIFACT}/web + cd dist && tar czf ${ARTIFACT}.tar.gz ${ARTIFACT} + + - name: Smoke test packaged artifact + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + run: | + set -euo pipefail + test -x "dist/${ARTIFACT}/codeg-server" + test -x "dist/${ARTIFACT}/codeg-mcp" + test -x "dist/${ARTIFACT}/codeg-computer-helper" + test -x "dist/${ARTIFACT}/codeg-cli" + test -f "dist/${ARTIFACT}/web/index.html" + + - name: Sign bundle with fork key + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" + run: | + set -euo pipefail + pnpm tauri signer sign --app-version "${{ steps.upstream.outputs.ver }}" "dist/${ARTIFACT}.tar.gz" + test -f "dist/${ARTIFACT}.tar.gz.sig" + ( cd dist && sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256 ) + + - name: Build latest.json + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + env: + VER: ${{ steps.upstream.outputs.ver }} + run: | + set -euo pipefail + SIG=$(base64 -w0 "dist/${ARTIFACT}.tar.gz.sig") + URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${{ steps.upstream.outputs.tag }}/${ARTIFACT}.tar.gz" + jq -n --arg v "$VER" --arg s "$SIG" --arg u "$URL" --rawfile n /tmp/upstream-notes.md \ + '{version: $v, notes: $n, pub_date: (now | todateiso8601), + platforms: {"linux-x86_64": {signature: $s, url: $u}}}' > dist/latest.json + jq -r '.version, .platforms["linux-x86_64"].url' dist/latest.json + + - name: Create fork release and upload + if: steps.skip.outputs.already == 'false' && steps.merge.outputs.merged == 'true' + env: + GH_TOKEN: ${{ secrets.FORK_SYNC_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release create "${{ steps.upstream.outputs.tag }}" \ + --repo "$GITHUB_REPOSITORY" \ + --title "codeg ${{ steps.upstream.outputs.ver }} + floating-ball (fork)" \ + --notes "基于上游 ${{ steps.upstream.outputs.tag }},含悬浮球功能与 fork 更新源补丁。仅打包 linux-x64。 + + $(cat /tmp/upstream-notes.md)" \ + "dist/${ARTIFACT}.tar.gz" \ + "dist/${ARTIFACT}.tar.gz.sig" \ + "dist/${ARTIFACT}.tar.gz.sha256" \ + dist/latest.json diff --git a/src-tauri/src/update/verify.rs b/src-tauri/src/update/verify.rs index 6401eff484..3748c9d17e 100644 --- a/src-tauri/src/update/verify.rs +++ b/src-tauri/src/update/verify.rs @@ -23,7 +23,7 @@ use crate::update::version::trim_v_prefix; /// Tauri-format minisign public key — copied verbatim from /// `tauri.conf.json` `plugins.updater.pubkey`. Base64 of the two-line /// `minisign.pub` file (`untrusted comment:` + `RW…` key line). -const TAURI_PUBKEY_B64: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ4OEM3NkMyMTVENjdBODgKUldTSWV0WVZ3bmFNU0NnSzhpdFg2bXFmMHFidWd1eWpuZ2Y2WmU5QmVXdWVrU0ZpOCt2dnd6WW4K"; +const TAURI_PUBKEY_B64: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1OEQ2MDVCMUU3Qjk4MjkKUldRcG1Ic2VXMkNOZFRWMTNRSHpRcGgvWjFBWmVNZVJZWi9MbGpZS1RpSEFBeGJyYTZ0cThUOHkK"; /// Decode an outer base64 wrapper into the inner minisign text file. fn unwrap_base64(b64: &str) -> Result { diff --git a/src-tauri/src/update/version.rs b/src-tauri/src/update/version.rs index 71715ae403..4e697e1717 100644 --- a/src-tauri/src/update/version.rs +++ b/src-tauri/src/update/version.rs @@ -16,12 +16,12 @@ use crate::app_error::AppCommandError; /// Update manifest URL — mirrors the `endpoints` entry in `tauri.conf.json` /// so desktop and server modes consult the same source of truth. pub const UPDATE_MANIFEST_URL: &str = - "https://github.com/spacering-net/codeg/releases/latest/download/latest.json"; + "https://github.com/unixcs/codeg/releases/latest/download/latest.json"; /// Deterministic base for "latest" release assets (server tarballs + their /// `.sig` detached signatures). Same channel as the manifest. pub const RELEASE_DOWNLOAD_BASE: &str = - "https://github.com/spacering-net/codeg/releases/latest/download"; + "https://github.com/unixcs/codeg/releases/latest/download"; /// Short-timeout client for the small manifest fetch. Proxy env vars are /// sampled at build time, so `init_proxy_from_db` must run before the first diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 40f9a21600..b1385ee4ca 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -57,7 +57,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ4OEM3NkMyMTVENjdBODgKUldTSWV0WVZ3bmFNU0NnSzhpdFg2bXFmMHFidWd1eWpuZ2Y2WmU5QmVXdWVrU0ZpOCt2dnd6WW4K", "endpoints": [ - "https://github.com/spacering-net/codeg/releases/latest/download/latest.json" + "https://github.com/unixcs/codeg/releases/latest/download/latest.json" ], "requireSignedVersion": true, "windows": { diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index c2c6c3e0f4..654edbd4fe 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -38,6 +38,11 @@ import { STORAGE_KEY_ZOOM_LEVEL, STORAGE_KEY_CHAT_ANIMATIONS, STORAGE_KEY_CHAT_CONTENT_WIDTH, + STORAGE_KEY_FLOATING_BALL_ENABLED, + STORAGE_KEY_FLOATING_BALL_POSITION, + STORAGE_KEY_FLOATING_BALL_BINDINGS, + STORAGE_KEY_FLOATING_BALL_SIZE, + STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS, STORAGE_KEY_WELCOME_QUICK_ACTIONS, STORAGE_KEY_UI_FONT, STORAGE_KEY_UI_FONT_CUSTOM, @@ -116,6 +121,35 @@ function syncAppearanceMode(mode: string) { export type FontSelection = { id: string; custom: string } +/** 悬浮球停靠位置(视口坐标 px,左上角原点)。 */ +export type FloatingBallPosition = { x: number; y: number } + +/** 缺省停靠:右下角,距右边与底边各 18px(底边让出安全区)。 */ +export const FLOATING_BALL_INSET = 18 + +/** + * 手势→动作绑定覆盖表。value 为 null 表示用户显式清除该手势(必须与"缺 key + * 回退默认"区分,否则清除功能失效);未出现的 key 一律取当前默认表。 + */ +export type FloatingBallBindingsOverride = Partial< + Record +> + +/** 球体三档大小:默认大(46px),中 40、小 36。 */ +export type FloatingBallSize = "small" | "medium" | "large" + +export const FLOATING_BALL_SIZE_PX: Record = { + small: 36, + medium: 40, + large: 46, +} + +export const DEFAULT_FLOATING_BALL_SIZE: FloatingBallSize = "large" + +function isFloatingBallSize(v: unknown): v is FloatingBallSize { + return v === "small" || v === "medium" || v === "large" +} + type AppearanceContextValue = { themeColor: ThemeColor setThemeColor: (color: ThemeColor) => void @@ -127,6 +161,21 @@ type AppearanceContextValue = { /** 聊天区域动画总开关,默认开启;关闭时 带 data-chat-animations="off" */ chatAnimations: boolean setChatAnimations: (on: boolean) => void + /** 悬浮球(手势入口)是否显示,默认关闭。 */ + floatingBallEnabled: boolean + setFloatingBallEnabled: (on: boolean) => void + /** 悬浮球停靠位置;null = 右下角缺省位。 */ + floatingBallPosition: FloatingBallPosition | null + setFloatingBallPosition: (position: FloatingBallPosition | null) => void + /** 手势绑定覆盖表(null = 用户清除);缺 key 回退默认表。 */ + floatingBallBindings: FloatingBallBindingsOverride + setFloatingBallBindings: (bindings: FloatingBallBindingsOverride) => void + /** 球体三档大小。 */ + floatingBallSize: FloatingBallSize + setFloatingBallSize: (size: FloatingBallSize) => void + /** 常用 agent 快切列表(agentType 数组,2-3 个)。 */ + floatingBallFavoriteAgents: string[] + setFloatingBallFavoriteAgents: (agents: string[]) => void /** * 聊天内容宽度(100% 缩放下的 px,落到 CSS 时换成 rem,随缩放变化), * null = 内置默认(48rem)。拖拽手柄与外观设置页共用;set 会持久化并落到 @@ -277,6 +326,69 @@ function readBool(key: string, def: boolean): boolean { } } +function readFloatingBallPosition(): FloatingBallPosition | null { + if (typeof document === "undefined") return null + try { + const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_POSITION) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== "object" || parsed === null) return null + const { x, y } = parsed as Record + if ( + typeof x !== "number" || + typeof y !== "number" || + !Number.isFinite(x) || + !Number.isFinite(y) + ) { + return null + } + return { x, y } + } catch { + return null + } +} + +function readFloatingBallBindings(): FloatingBallBindingsOverride { + if (typeof document === "undefined") return {} + try { + const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_BINDINGS) + if (!raw) return {} + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== "object" || parsed === null) return {} + const out: FloatingBallBindingsOverride = {} + for (const [k, v] of Object.entries(parsed as Record)) { + // null survives on purpose: it means "user cleared this gesture". + if (v === null || typeof v === "string") out[k] = v + } + return out + } catch { + return {} + } +} + +function readFloatingBallSize(): FloatingBallSize { + if (typeof document === "undefined") return DEFAULT_FLOATING_BALL_SIZE + try { + const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_SIZE) + return isFloatingBallSize(raw) ? raw : DEFAULT_FLOATING_BALL_SIZE + } catch { + return DEFAULT_FLOATING_BALL_SIZE + } +} + +function readFloatingBallFavoriteAgents(): string[] { + if (typeof document === "undefined") return [] + try { + const raw = localStorage.getItem(STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((v): v is string => typeof v === "string").slice(0, 5) + } catch { + return [] + } +} + function readNumber( key: string, def: number, @@ -418,6 +530,19 @@ export function AppearanceProvider({ } }, [chatAnimations]) + // 悬浮球:默认不显示,显式开启后才挂载;位置缺省为右下角。 + const [floatingBallEnabled, setFloatingBallEnabledState] = useState( + () => readBool(STORAGE_KEY_FLOATING_BALL_ENABLED, false) + ) + const [floatingBallPosition, setFloatingBallPositionState] = + useState(() => readFloatingBallPosition()) + const [floatingBallBindings, setFloatingBallBindingsState] = + useState(() => readFloatingBallBindings()) + const [floatingBallSize, setFloatingBallSizeState] = + useState(() => readFloatingBallSize()) + const [floatingBallFavoriteAgents, setFloatingBallFavoriteAgentsState] = + useState(() => readFloatingBallFavoriteAgents()) + // 聊天内容宽度:DOM 变量由 inline 脚本预置,这里持有状态供设置页回显。 const [chatContentWidth, setChatContentWidthState] = useState( () => readChatContentWidth() @@ -582,6 +707,60 @@ export function AppearanceProvider({ persist(STORAGE_KEY_CHAT_ANIMATIONS, on ? "1" : "0") }, []) + const setFloatingBallEnabled = useCallback((on: boolean) => { + setFloatingBallEnabledState(on) + persist(STORAGE_KEY_FLOATING_BALL_ENABLED, on ? "1" : "0") + }, []) + + const setFloatingBallPosition = useCallback( + (position: FloatingBallPosition | null) => { + setFloatingBallPositionState(position) + if (position === null) { + try { + localStorage.removeItem(STORAGE_KEY_FLOATING_BALL_POSITION) + } catch { + // 存不进去也不影响本窗口——位置只是便利,不是数据。 + } + return + } + persist( + STORAGE_KEY_FLOATING_BALL_POSITION, + JSON.stringify({ x: Math.round(position.x), y: Math.round(position.y) }) + ) + }, + [] + ) + + const setFloatingBallBindings = useCallback( + (bindings: FloatingBallBindingsOverride) => { + setFloatingBallBindingsState(bindings) + const hasKeys = Object.keys(bindings).length > 0 + if (!hasKeys) { + try { + localStorage.removeItem(STORAGE_KEY_FLOATING_BALL_BINDINGS) + } catch { + // 存不进去也不影响本窗口——绑定只是便利,不是数据。 + } + return + } + persist(STORAGE_KEY_FLOATING_BALL_BINDINGS, JSON.stringify(bindings)) + }, + [] + ) + + const setFloatingBallSize = useCallback((size: FloatingBallSize) => { + setFloatingBallSizeState(size) + persist(STORAGE_KEY_FLOATING_BALL_SIZE, size) + }, []) + + const setFloatingBallFavoriteAgents = useCallback((agents: string[]) => { + setFloatingBallFavoriteAgentsState(agents) + persist( + STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS, + JSON.stringify(agents.slice(0, 5)) + ) + }, []) + const setChatContentWidth = useCallback((px: number | null) => { chatContentWidthRef.current = px setChatContentWidthState(px) @@ -1033,6 +1212,24 @@ export function AppearanceProvider({ if (e.key === STORAGE_KEY_CHAT_ANIMATIONS) { setChatAnimationsState(readBool(STORAGE_KEY_CHAT_ANIMATIONS, true)) } + // 悬浮球同样在外观设置里开关,那边的改动要立刻反映到工作区窗口。 + if (e.key === STORAGE_KEY_FLOATING_BALL_ENABLED) { + setFloatingBallEnabledState( + readBool(STORAGE_KEY_FLOATING_BALL_ENABLED, false) + ) + } + if (e.key === STORAGE_KEY_FLOATING_BALL_POSITION) { + setFloatingBallPositionState(readFloatingBallPosition()) + } + if (e.key === STORAGE_KEY_FLOATING_BALL_BINDINGS) { + setFloatingBallBindingsState(readFloatingBallBindings()) + } + if (e.key === STORAGE_KEY_FLOATING_BALL_SIZE) { + setFloatingBallSizeState(readFloatingBallSize()) + } + if (e.key === STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS) { + setFloatingBallFavoriteAgentsState(readFloatingBallFavoriteAgents()) + } if (e.key && FONT_KEYS.has(e.key)) { rehydrateFonts() } @@ -1139,6 +1336,16 @@ export function AppearanceProvider({ setShowWelcomeQuickActions, chatAnimations, setChatAnimations, + floatingBallEnabled, + setFloatingBallEnabled, + floatingBallPosition, + setFloatingBallPosition, + floatingBallBindings, + setFloatingBallBindings, + floatingBallSize, + setFloatingBallSize, + floatingBallFavoriteAgents, + setFloatingBallFavoriteAgents, chatContentWidth, setChatContentWidth, previewChatContentWidth: applyChatContentWidth, diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 4458a603a3..760b3fbcb0 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -28,6 +28,8 @@ interface ChatInputProps { agentName?: string onFocus?: () => void onSend: (draft: PromptDraft, modeId?: string | null) => void + /** Pass-through: see `MessageInput.modeSelectorSignal`. */ + modeSelectorSignal?: number onCancel: () => void modes?: SessionModeInfo[] configOptions?: SessionConfigOptionInfo[] @@ -109,6 +111,7 @@ export const ChatInput = memo(function ChatInput({ agentName, onFocus, onSend, + modeSelectorSignal, onCancel, modes, configOptions, @@ -205,6 +208,7 @@ export const ChatInput = memo(function ChatInput({ )} void + /** Pass-through: see `MessageInput.modeSelectorSignal`. */ + modeSelectorSignal?: number onConfigOptionChange?: (configId: string, valueId: string) => void agentType?: AgentType | null availableCommands?: AvailableCommandInfo[] | null @@ -199,6 +201,7 @@ export function ConversationShell({ selectorsLoading = false, selectedModeId, onModeChange, + modeSelectorSignal, onConfigOptionChange, agentType, availableCommands, @@ -318,6 +321,7 @@ export function ConversationShell({ {!hideInput && (
void + /** Bumped by the host (floating ball "mode selector" action) to open the + * inline mode dropdown; the effect below re-fires on every increment. */ + modeSelectorSignal?: number placeholder?: string defaultPath?: string disabled?: boolean @@ -311,6 +314,7 @@ function SelectorLoadingChip({ label }: { label: string }) { export function MessageInput({ onSend, + modeSelectorSignal: modeSelectorSignalProp = 0, placeholder, defaultPath, disabled = false, @@ -907,6 +911,17 @@ export function MessageInput({ // Antigravity after its commands on `/`. const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) + // One-shot signal from the floating ball's "mode selector" action: the + // imperative openModeSelector handle bumps a counter; the effect below + // re-opens the dropdown when it advances. Programmatic trigger clicks are + // idempotent while the menu is already open. + useEffect(() => { + if (modeSelectorSignalProp === 0) return + const trigger = containerRef.current?.querySelector( + "[data-mode-selector-trigger]" + ) + trigger?.click() + }, [modeSelectorSignalProp]) // The trigger char (`/` for agent commands, `$` for Codex skills) and the // typed filter token, both derived from the editor caret by // `detectSlashTrigger` rather than from a raw string offset. diff --git a/src/components/chat/mode-selector.tsx b/src/components/chat/mode-selector.tsx new file mode 100644 index 0000000000..94019be7ec --- /dev/null +++ b/src/components/chat/mode-selector.tsx @@ -0,0 +1,71 @@ +"use client" + +import { ChevronDown } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" +import { SelectorTooltip } from "@/components/chat/selector-tooltip" +import type { SessionModeInfo } from "@/lib/types" + +interface ModeSelectorProps { + modes: SessionModeInfo[] + selectedModeId: string | null + onSelect: (modeId: string) => void + label: string +} + +export function InlineModeSelector({ + modes, + selectedModeId, + onSelect, + label, +}: ModeSelectorProps) { + const selected = modes.find((mode) => mode.id === selectedModeId) + const currentLabel = selected?.name ?? selectedModeId ?? "" + return ( + + + + + + + + + {modes.map((mode) => ( + + + + ))} + + + + ) +} diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index a24c3bc7a4..49a75ed255 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -1,6 +1,6 @@ "use client" -import { memo, useCallback, useState } from "react" +import { memo, useCallback, useEffect, useState } from "react" import { ChevronRight, Circle, @@ -21,6 +21,8 @@ import { updateConversationTitle, } from "@/lib/api" import { formatConversationTitle } from "@/lib/conversation-title" +import { RENAME_CONVERSATION_EVENT } from "@/lib/rename-conversation-events" +import { DELETE_CONVERSATION_EVENT } from "@/lib/floating-ball-session-events" import { ConversationHeaderFolderPicker } from "@/components/chat/conversation-context-bar" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions } from "@/contexts/tab-context" @@ -64,6 +66,7 @@ import { import { SessionDetailsDialog } from "./session-details-dialog" import { ConversationHeaderTags } from "./conversation-tag-picker" import { useIsMobile } from "@/hooks/use-mobile" +import { forgetRecentConversation } from "@/lib/recent-sessions-prefs" interface ConversationDetailHeaderProps { tabId: string @@ -211,6 +214,36 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ setRenameTarget(null) }, [renameTarget, renameValue, refreshConversations]) + // Floating-ball "rename session": the event carries the DB id and every + // mounted header instance hears it — only the one whose conversation matches + // opens its dialog (multi-tab safe). Drafts (id null) have nothing to rename. + useEffect(() => { + if (conversationId == null) return + const onRename = (e: Event) => { + const id = (e as CustomEvent<{ conversationId: number }>).detail + ?.conversationId + if (id !== conversationId || renameTarget != null) return + setRenameValue(title || "") + setRenameTarget({ id: conversationId, title }) + } + window.addEventListener(RENAME_CONVERSATION_EVENT, onRename) + return () => window.removeEventListener(RENAME_CONVERSATION_EVENT, onRename) + }, [conversationId, renameTarget, title]) + + // Floating-ball "delete session": reuse this header's existing confirm + // dialog instead of minting a second one (same multi-tab idiom as rename). + useEffect(() => { + if (conversationId == null) return + const onDelete = (e: Event) => { + const id = (e as CustomEvent<{ conversationId: number | null }>).detail + ?.conversationId + if (id !== conversationId || deleteTarget != null) return + setDeleteTarget({ id: conversationId, tabId, title: displayTitle }) + } + window.addEventListener(DELETE_CONVERSATION_EVENT, onDelete) + return () => window.removeEventListener(DELETE_CONVERSATION_EVENT, onDelete) + }, [conversationId, deleteTarget, tabId, displayTitle]) + const handleStatusChange = useCallback( (next: ConversationStatus) => { if (conversationId == null) return @@ -232,6 +265,9 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ if (deleteTarget == null) return try { await deleteConversation(deleteTarget.id) + // Keep the floating ball's recent-sessions LRU from cycling into a + // conversation that no longer exists. + forgetRecentConversation(deleteTarget.id) // The deleted conversation is gone — close its tab and refresh the list. // Not recorded for reopen: there is no longer a conversation to reopen. closeTab(deleteTarget.tabId, { recordForReopen: false }) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 3ac574c9bb..863077da99 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -145,6 +145,12 @@ import { getSavedModeId, saveModePreference, } from "@/lib/selector-prefs-storage" +import { matchFullAccessMode } from "@/lib/default-mode-prefs" +import { + eventMatchesConversation, + FOCUS_MODE_SELECTOR_EVENT, + TOGGLE_PLAN_FULL_EVENT, +} from "@/lib/floating-ball-session-events" import { adoptLegacyNewConversationDraft, buildConversationDraftStorageKey, @@ -273,6 +279,7 @@ const ConversationTabView = memo(function ConversationTabView({ // Composer-namespace copy for the queue row's click-to-insert outcomes // (same keys the composer's own mid-turn send reports). const tCmp = useTranslations("Folder.chat.messageInput") + const tBall = useTranslations("FloatingBall") const tWelcome = useTranslations("Folder.chat.welcomeInputPanel") const tDiag = useTranslations("DiagnosticsSettings") const sharedT = useTranslations("Folder.chat.shared") @@ -1624,6 +1631,63 @@ const ConversationTabView = memo(function ConversationTabView({ [effectiveModes, selectedAgent] ) + // Floating-ball "Plan ⇄ full access": classify the advertised modes into a + // plan side and a full-access side (id substring weights, same helper the + // default-mode logic uses), then hop to the other side. Falls through to + // Plan when the current mode is neither; toasts when Plan doesn't exist. + const handleTogglePlanFull = useCallback(() => { + const availableModes = effectiveModes?.available_modes ?? [] + if (availableModes.length === 0) { + toast.info(tBall("toasts.noModesAvailable")) + return + } + const current = selectedModeIdRef.current + const availableIds = availableModes.map((m) => m.id) + const planId = availableIds.find((id) => id.toLowerCase().includes("plan")) + const fullId = matchFullAccessMode(availableIds) + const target = + current != null && planId != null && current === planId + ? (fullId ?? null) + : planId + if (target == null) { + toast.info(tBall("toasts.noSwitchTarget")) + return + } + handleModeChange(target) + // Sync the agent immediately — a mode change should not wait for the next + // send's catch-up diff, especially for the full-access side. + if (target !== conn.modes?.current_mode_id) { + void acpActions.setMode(tabId, target).catch((err) => { + console.error("[ConversationDetailPanel] fb setMode:", err) + }) + } + }, [acpActions, conn.modes, effectiveModes, handleModeChange, tabId, tBall]) + + // Floating-ball hand-offs (delete → header's confirm dialog; mode selector / + // plan toggle → this panel's composer). Events carry the DB id and every + // mounted panel instance hears them — only the one whose conversation + // matches reacts (multi-tab safe, same idiom as rename). + const [modeSelectorSignal, setModeSelectorSignal] = useState(0) + useEffect(() => { + const match = (e: Event) => + eventMatchesConversation( + (e as CustomEvent<{ conversationId: number | null }>).detail, + dbConversationId + ) + const onFocusMode = (e: Event) => { + if (match(e)) setModeSelectorSignal((n) => n + 1) + } + const onTogglePlan = (e: Event) => { + if (match(e)) handleTogglePlanFull() + } + window.addEventListener(FOCUS_MODE_SELECTOR_EVENT, onFocusMode) + window.addEventListener(TOGGLE_PLAN_FULL_EVENT, onTogglePlan) + return () => { + window.removeEventListener(FOCUS_MODE_SELECTOR_EVENT, onFocusMode) + window.removeEventListener(TOGGLE_PLAN_FULL_EVENT, onTogglePlan) + } + }, [dbConversationId, handleTogglePlanFull]) + const handleAnswerQuestion = useCallback( (answer: string) => { if (connStatus !== "connected") return @@ -2299,6 +2363,7 @@ const ConversationTabView = memo(function ConversationTabView({ return ( @@ -2474,6 +2539,7 @@ const ConversationTabView = memo(function ConversationTabView({
) : null} ({ + enabled: false, + position: null as { x: number; y: number } | null, + setPosition: vi.fn(), + bindings: {} as Record, + size: "large" as "small" | "medium" | "large", + favoriteAgents: [] as string[], +})) + +vi.mock("@/hooks/use-appearance", () => ({ + useFloatingBall: () => ({ + floatingBallEnabled: appearance.enabled, + setFloatingBallEnabled: vi.fn(), + floatingBallPosition: appearance.position, + setFloatingBallPosition: appearance.setPosition, + floatingBallBindings: appearance.bindings, + setFloatingBallBindings: vi.fn(), + floatingBallSize: appearance.size, + setFloatingBallSize: vi.fn(), + floatingBallFavoriteAgents: appearance.favoriteAgents, + setFloatingBallFavoriteAgents: vi.fn(), + }), +})) + +import { FloatingBall } from "./floating-ball" + +function renderBall(onAction = vi.fn()) { + render( + + + + ) + return onAction +} + +/** + * jsdom implements neither `PointerEvent` nor coordinates on it, so + * `fireEvent.pointerDown(el, { clientX })` arrives with nulls. A `MouseEvent` + * carrying the pointer event's type does reach React's handler with real + * coordinates, which is all the component reads. + */ +function pointer( + el: Element, + type: "pointerdown" | "pointermove" | "pointerup", + x: number, + y: number, + pointerId = 1 +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + button: 0, + }) + Object.defineProperty(event, "pointerId", { value: pointerId }) + Object.defineProperty(event, "pointerType", { value: "touch" }) + fireEvent(el, event) +} + +/** A press that never moves, held for `heldMs`. */ +function tap(heldMs = 50) { + const ball = screen.getByLabelText("Floating ball") + pointer(ball, "pointerdown", 100, 100) + vi.advanceTimersByTime(heldMs) + pointer(ball, "pointerup", 100, 100) + return ball +} + +/** A press that moves in a straight line and releases. */ +function swipe(from: [number, number], to: [number, number]) { + const ball = screen.getByLabelText("Floating ball") + pointer(ball, "pointerdown", from[0], from[1]) + // Within GESTURE_ONSET_MS, so this is a gesture and not a drag. + pointer( + ball, + "pointermove", + from[0] + (to[0] - from[0]) / 2, + from[1] + (to[1] - from[1]) / 2 + ) + pointer(ball, "pointermove", to[0], to[1]) + pointer(ball, "pointerup", to[0], to[1]) +} + +beforeEach(() => { + localStorage.clear() + appearance.enabled = false + appearance.position = null + appearance.setPosition.mockClear() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + cleanup() +}) + +describe("FloatingBall", () => { + it("renders nothing until the preference is enabled", () => { + renderBall() + expect(screen.queryByLabelText("Floating ball")).toBeNull() + }) + + it("shows the ball when enabled", () => { + appearance.enabled = true + renderBall() + expect(screen.getByLabelText("Floating ball")).toBeInTheDocument() + }) + + it("defaults to the right edge, clear of the composer and status bar", () => { + appearance.enabled = true + renderBall() + const ball = screen.getByLabelText("Floating ball") as HTMLElement + const x = parseFloat(ball.style.left) + const y = parseFloat(ball.style.top) + // Flush to the trailing edge... + expect(x).toBe(window.innerWidth - 46 - 18) + // ...but well above the bottom, where the composer's send button and the + // status bar's alerts/MCP badges sit. + expect(y).toBe(Math.round(window.innerHeight * 0.55)) + expect(y).toBeLessThan(window.innerHeight - 46 - 18) + }) + + it("opens the action panel on a single tap, after the double-tap window", () => { + appearance.enabled = true + const onAction = renderBall() + tap() + // Deferred so a double tap does not flash the panel first. + expect(screen.queryByRole("dialog")).toBeNull() + act(() => { + vi.advanceTimersByTime(300) + }) + expect(screen.getByRole("dialog")).toBeInTheDocument() + expect(screen.getByText("New conversation")).toBeInTheDocument() + expect(onAction).not.toHaveBeenCalled() + }) + + it("treats a second tap inside the window as a double tap", () => { + appearance.enabled = true + const onAction = renderBall() + tap() + act(() => { + vi.advanceTimersByTime(100) + }) + tap() + // Batch v86: the double tap moved from the terminal to the right panel. + expect(onAction).toHaveBeenCalledWith("toggleAuxPanel") + // The pending single tap must not fire as well. + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onAction).toHaveBeenCalledTimes(1) + }) + + it("opens the in-ball settings on a long press", () => { + appearance.enabled = true + const onAction = renderBall() + tap(700) + expect(screen.getByRole("dialog")).toBeInTheDocument() + expect(screen.getByText("Reset position")).toBeInTheDocument() + expect(onAction).not.toHaveBeenCalled() + }) + + it("fires the bound action for a swipe", () => { + appearance.enabled = true + const onAction = renderBall() + // Left = new conversation. + swipe([200, 300], [120, 300]) + expect(onAction).toHaveBeenCalledWith("newConversation") + }) + + it("recognises a two-stage trail", () => { + appearance.enabled = true + const onAction = renderBall() + const ball = screen.getByLabelText("Floating ball") + pointer(ball, "pointerdown", 200, 400) + pointer(ball, "pointermove", 200, 360) + pointer(ball, "pointermove", 200, 320) + pointer(ball, "pointermove", 200, 400) + pointer(ball, "pointermove", 200, 460) + pointer(ball, "pointerup", 200, 460) + // Up, then down = open folder. + expect(onAction).toHaveBeenCalledWith("openFolder") + }) + + it("stores the new position after a drag", () => { + appearance.enabled = true + renderBall() + const ball = screen.getByLabelText("Floating ball") as HTMLElement + // The ball's own corner is what moves, not the pointer's, so the assertion + // is on the delta from where it started. + const startX = parseFloat(ball.style.left) + const startY = parseFloat(ball.style.top) + pointer(ball, "pointerdown", 300, 500) + // Moving only after the onset window makes this a repositioning drag. + act(() => { + vi.advanceTimersByTime(400) + }) + pointer(ball, "pointermove", 260, 440) + pointer(ball, "pointerup", 260, 440) + expect(appearance.setPosition).toHaveBeenCalled() + const stored = appearance.setPosition.mock.calls[0][0] as { + x: number + y: number + } + expect(stored.x).toBe(startX - 40) + expect(stored.y).toBe(startY - 60) + }) + + it("closes the panel on Escape", () => { + appearance.enabled = true + renderBall() + tap() + act(() => { + vi.advanceTimersByTime(300) + }) + expect(screen.getByRole("dialog")).toBeInTheDocument() + fireEvent.keyDown(document, { key: "Escape" }) + expect(screen.queryByRole("dialog")).toBeNull() + }) +}) diff --git a/src/components/layout/floating-ball.tsx b/src/components/layout/floating-ball.tsx new file mode 100644 index 0000000000..e62ef6d86b --- /dev/null +++ b/src/components/layout/floating-ball.tsx @@ -0,0 +1,657 @@ +"use client" + +import { Command, X } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslations } from "next-intl" + +import { + FLOATING_BALL_INSET, + FLOATING_BALL_SIZE_PX, + type FloatingBallPosition, + type FloatingBallSize, +} from "@/components/appearance-provider" +import { useFloatingBall } from "@/hooks/use-appearance" +import { + createFloatingBallTracker, + DEFAULT_FLOATING_BALL_BINDINGS, + DOUBLE_TAP_MS, + GESTURE_ONSET_MS, + LONG_PRESS_MS, + MOVE_PX, + previewGesture, + resolveTapGesture, + type FloatingBallAction, + type FloatingBallGesture, +} from "@/lib/floating-ball-gestures" +import { cn } from "@/lib/utils" + +const PANEL_WIDTH = 208 + +/** + * How far the ball follows the finger during a gesture, as a fraction of the + * viewport. MindFS clamps its ring to ±64px because it lives inside an input + * row; ours is free-floating, so the follow distance scales with the screen. + */ +const GESTURE_FOLLOW_RATIO = 0.18 + +const ICON_CLASS: Record = { + small: "size-4", + medium: "size-[18px]", + large: "size-5", +} + +/** The rows the tap panel offers, in reading order. */ +const PANEL_ACTIONS: readonly Exclude[] = [ + "newConversation", + "toggleSidebar", + "toggleAuxPanel", + "toggleSearch", + "toggleTerminal", + "nextTab", + "prevTab", + "reopenClosedTab", + "openFolder", + "openTodos", + "openTodosNew", + "openAutomations", + "openCanvas", + "openAgents", + "openSystemAdmin", + "openSessionDetails", + "openChanges", + "openGitLog", + "renameConversation", + "openSessionFolder", + "cycleAgent", + "cycleFavoriteAgent", + "zoomIn", + "zoomOut", + "zoomReset", + "stopGenerating", + "openSettings", +] + +/** Gestures shown in the cheat sheet, with the action they fire. */ +const CHEAT_SHEET: readonly FloatingBallGesture[] = [ + "tap", + "doubleTap", + "longPress", + "left", + "right", + "up", + "down", + "upLeft", + "upRight", + "downLeft", + "downRight", + "upThenLeft", + "upThenRight", + "upDown", + "downUp", + "rightThenUp", + "rightThenDown", + "downThenRight", + "downThenLeft", + "upThenDownRight", + "downThenUpRight", + "downRightThenUp", + "downLeftThenUp", + "leftThenRight", + "rightThenLeft", + "upLeftThenDown", + "upRightThenDown", +] + +interface FloatingBallProps { + /** Runs an action against the workspace (tab, panel, zoom, agent turn). */ + onAction: (action: FloatingBallAction) => void +} + +function clampPosition( + position: FloatingBallPosition, + viewport: { width: number; height: number }, + ballSize: number +): FloatingBallPosition { + const maxX = Math.max(0, viewport.width - ballSize - 4) + const maxY = Math.max(0, viewport.height - ballSize - 4) + return { + x: Math.min(Math.max(4, position.x), maxX), + y: Math.min(Math.max(4, position.y), maxY), + } +} + +/** + * Where a freshly enabled ball lands: the right edge, a little below the + * vertical middle. + * + * The bottom-right corner is not free in this workspace. The composer's send + * button occupies the corner above the status bar, and the status bar itself + * carries the MCP health light and the alerts badge — measured at 390×844 and + * 1280×800, both covered by the old corner default. The right edge around 55% + * is the one band that is empty on both phone and desktop. + */ +const DEFAULT_Y_RATIO = 0.55 + +function defaultPosition( + viewport: { width: number; height: number }, + ballSize: number +): FloatingBallPosition { + return { + x: viewport.width - ballSize - FLOATING_BALL_INSET, + y: Math.round(viewport.height * DEFAULT_Y_RATIO), + } +} + +function readViewport(): { width: number; height: number } { + if (typeof window === "undefined") return { width: 0, height: 0 } + // `visualViewport` is what shrinks when the on-screen keyboard opens; the + // layout viewport does not, so anchoring to it would park the ball behind + // the keyboard. + const vv = window.visualViewport + return { + width: Math.round(vv?.width ?? window.innerWidth), + height: Math.round(vv?.height ?? window.innerHeight), + } +} + +/** + * A draggable ball over the workspace that turns swipes into the actions the + * keyboard shortcuts already perform — the touch counterpart of those + * shortcuts, for the phone, where there is no keyboard. + * + * The gesture vocabulary comes from MindFS's blue-ring menu so the muscle + * memory carries over; see `floating-ball-gestures.ts`. Actions are dispatched + * by the caller, which owns the workspace contexts. Bindings and the ball's + * size are owner-configurable (appearance settings); a swipe shows a live hint + * pill naming the action the current trail would fire. + */ +export function FloatingBall({ onAction }: FloatingBallProps) { + const t = useTranslations("FloatingBall") + const { + floatingBallEnabled, + floatingBallPosition, + setFloatingBallPosition, + floatingBallBindings, + floatingBallSize, + } = useFloatingBall() + + const BALL_SIZE = FLOATING_BALL_SIZE_PX[floatingBallSize] + + const [viewport, setViewport] = useState(readViewport) + // The stored position is the only source of truth: deriving the on-screen + // position from it keeps rotation, window resizing and a reset from another + // window consistent without copying it into local state. + const position = floatingBallPosition + ? clampPosition(floatingBallPosition, viewport, BALL_SIZE) + : defaultPosition(viewport, BALL_SIZE) + const [panelOpen, setPanelOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + // Live gesture feedback: the ball follows the finger and a pill names the + // action the trail would commit. Both reset on lift-off. + const [follow, setFollow] = useState<{ x: number; y: number } | null>(null) + const [hint, setHint] = useState(null) + + const ballRef = useRef(null) + const trackerRef = useRef(createFloatingBallTracker()) + const dragRef = useRef<{ + pointerId: number + /** Where the ball sat when the press began, in viewport px. */ + origin: FloatingBallPosition + /** Where the finger went down. */ + startX: number + startY: number + startedAt: number + mode: "undecided" | "gesture" | "drag" + } | null>(null) + const lastTapAtRef = useRef(null) + const tapTimerRef = useRef | null>(null) + const frameRef = useRef(null) + + const resolveAction = useCallback( + (gesture: FloatingBallGesture): FloatingBallAction | null => { + const override = floatingBallBindings[gesture] as + | FloatingBallAction + | null + | undefined + if (override !== undefined) return override + return DEFAULT_FLOATING_BALL_BINDINGS[gesture] + }, + [floatingBallBindings] + ) + + // Keep the viewport current: it decides both the default corner and where an + // off-screen ball gets clamped back to. + useEffect(() => { + const onResize = () => setViewport(readViewport()) + window.addEventListener("resize", onResize) + window.visualViewport?.addEventListener("resize", onResize) + return () => { + window.removeEventListener("resize", onResize) + window.visualViewport?.removeEventListener("resize", onResize) + } + }, []) + + const clearTapTimer = useCallback(() => { + if (tapTimerRef.current !== null) { + clearTimeout(tapTimerRef.current) + tapTimerRef.current = null + } + }, []) + + useEffect(() => clearTapTimer, [clearTapTimer]) + + const fire = useCallback( + (gesture: FloatingBallGesture) => { + const action = resolveAction(gesture) + if (!action) return + if (action === "panel") { + setSettingsOpen(false) + setPanelOpen((open) => !open) + return + } + if (action === "openSettings") { + setPanelOpen(false) + setSettingsOpen((open) => !open) + return + } + setPanelOpen(false) + setSettingsOpen(false) + onAction(action) + }, + [onAction, resolveAction] + ) + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0 && event.pointerType === "mouse") return + const now = Date.now() + dragRef.current = { + pointerId: event.pointerId, + origin: position, + startX: event.clientX, + startY: event.clientY, + startedAt: now, + mode: "undecided", + } + trackerRef.current.start(event.clientX, event.clientY) + event.currentTarget.setPointerCapture(event.pointerId) + }, + [position] + ) + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + const dx = event.clientX - drag.startX + const dy = event.clientY - drag.startY + const distance = Math.hypot(dx, dy) + + if (drag.mode === "undecided" && distance > MOVE_PX) { + // Fast movement is a gesture; a press that only starts moving after + // the onset window is the user repositioning the ball. + drag.mode = + Date.now() - drag.startedAt < GESTURE_ONSET_MS ? "gesture" : "drag" + } + + if (drag.mode === "drag") { + event.preventDefault() + const next = clampPosition( + { x: drag.origin.x + dx, y: drag.origin.y + dy }, + readViewport(), + BALL_SIZE + ) + // Write the frame directly: a state update per pointer sample would + // re-render the whole overlay tree on every move. + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current) + frameRef.current = requestAnimationFrame(() => { + if (ballRef.current) { + ballRef.current.style.left = `${next.x}px` + ballRef.current.style.top = `${next.y}px` + } + frameRef.current = null + }) + return + } + + if (drag.mode === "gesture") { + event.preventDefault() + trackerRef.current.move(event.clientX, event.clientY) + // The ball trails the finger, clamped to a fraction of the viewport so + // a long swipe cannot throw it off-screen; both reset on lift-off. The + // trail offset is gesture-only visual feedback — the stored position + // never moves during a gesture. + const followLimit = Math.round( + Math.min(viewport.width, viewport.height) * GESTURE_FOLLOW_RATIO + ) + setFollow({ + x: Math.max(-followLimit, Math.min(followLimit, dx)), + y: Math.max(-followLimit, Math.min(followLimit, dy)), + }) + setHint( + (() => { + const gesture = previewGesture(trackerRef.current.trail) + return gesture ? resolveAction(gesture) : null + })() + ) + } + }, + [BALL_SIZE, resolveAction, viewport] + ) + + const onPointerUp = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + dragRef.current = null + setFollow(null) + setHint(null) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + if (drag.mode === "drag") { + const dx = event.clientX - drag.startX + const dy = event.clientY - drag.startY + setFloatingBallPosition( + clampPosition( + { x: drag.origin.x + dx, y: drag.origin.y + dy }, + readViewport(), + BALL_SIZE + ) + ) + return + } + + if (drag.mode === "gesture") { + const gesture = trackerRef.current.end() + trackerRef.current.reset() + if (gesture) fire(gesture) + return + } + + // A press that never moved: tap, double tap or long press. + const now = Date.now() + const heldMs = now - drag.startedAt + const sinceLastTap = + lastTapAtRef.current === null ? null : now - lastTapAtRef.current + const gesture = resolveTapGesture(heldMs, sinceLastTap) + + if (gesture === "longPress") { + clearTapTimer() + lastTapAtRef.current = null + fire("longPress") + return + } + if (gesture === "doubleTap") { + clearTapTimer() + lastTapAtRef.current = null + fire("doubleTap") + return + } + + // A single tap is only known to be single once the double-tap window + // has passed, so it waits — otherwise the panel would flash open before + // a double tap could replace it. + lastTapAtRef.current = now + clearTapTimer() + tapTimerRef.current = setTimeout(() => { + tapTimerRef.current = null + lastTapAtRef.current = null + fire("tap") + }, DOUBLE_TAP_MS) + }, + [BALL_SIZE, clearTapTimer, fire, setFloatingBallPosition] + ) + + const onPointerCancel = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + dragRef.current = null + trackerRef.current.reset() + setFollow(null) + setHint(null) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + }, + [] + ) + + // Dismiss either surface on an outside press or Escape. + useEffect(() => { + if (!panelOpen && !settingsOpen) return + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node | null + if (!target) return + if (ballRef.current?.contains(target)) return + if ((target as Element).closest?.("[data-floating-ball-surface]")) return + setPanelOpen(false) + setSettingsOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + setPanelOpen(false) + setSettingsOpen(false) + } + document.addEventListener("pointerdown", onPointerDown, true) + document.addEventListener("keydown", onKeyDown) + return () => { + document.removeEventListener("pointerdown", onPointerDown, true) + document.removeEventListener("keydown", onKeyDown) + } + }, [panelOpen, settingsOpen]) + + if (!floatingBallEnabled) return null + + // The panel opens toward the middle of the screen: above the ball when it + // sits in the lower half, below it otherwise. Its height is capped to the + // space actually left on that side, so a long action list scrolls instead of + // pushing the header off-screen. + const opensUpward = position.y > viewport.height / 2 + const panelBottom = opensUpward ? viewport.height - position.y + 8 : undefined + const panelTop = opensUpward ? undefined : position.y + BALL_SIZE + 8 + const panelMaxHeight = Math.max( + 120, + (opensUpward + ? position.y - 8 + : viewport.height - position.y - BALL_SIZE - 8) - 8 + ) + const panelLeft = Math.min( + Math.max(8, position.x + BALL_SIZE - PANEL_WIDTH), + Math.max(8, viewport.width - PANEL_WIDTH - 8) + ) + + const cheatRows = CHEAT_SHEET.map((gesture) => ({ + gesture, + action: resolveAction(gesture), + })).filter( + ( + row + ): row is { gesture: FloatingBallGesture; action: FloatingBallAction } => + row.action !== null + ) + + return ( + <> + + + {hint && ( +
+ {hint === "panel" ? t("panelTitle") : t(`actions.${hint}`)} +
+ )} + + {panelOpen && ( +
+
+ {t("panelTitle")} + +
+
+ {PANEL_ACTIONS.map((action) => ( + + ))} +
+
+ )} + + {settingsOpen && ( +
+
+ {t("settingsTitle")} + +
+
+ +
+

+ {t("cheatSheetTitle")} +

+
    + {cheatRows.map(({ gesture, action }) => ( +
  • + + {t(`gestures.${gesture}`)} + + + {action === "panel" + ? t("panelTitle") + : t(`actions.${action}`)} + +
  • + ))} +
+
+

+ {t("hint")} +

+
+
+ )} + + ) +} + +export { LONG_PRESS_MS } diff --git a/src/components/layout/workspace-chrome-controller.test.tsx b/src/components/layout/workspace-chrome-controller.test.tsx index 6805b13ffa..9688132a7f 100644 --- a/src/components/layout/workspace-chrome-controller.test.tsx +++ b/src/components/layout/workspace-chrome-controller.test.tsx @@ -25,6 +25,15 @@ const state = vi.hoisted(() => ({ desktop: true, shortcuts: {} as Record, listeners: new Map void>(), + // Floating-ball action context: the active tab row and the agent universe. + activeTab: null as { + id: string + conversationId: number | null + agentType: string + } | null, + sortedAgentTypes: ["zcode", "codex"] as string[], + favoriteAgents: [] as string[], + activeFolder: { id: 1, path: "/repo" } as { id: number; path: string } | null, })) const spies = vi.hoisted(() => ({ @@ -36,11 +45,54 @@ const spies = vi.hoisted(() => ({ setActivePane: vi.fn(), closeCurrentWindow: vi.fn(() => Promise.resolve()), subscribe: vi.fn(), + setRoute: vi.fn(), + openSettingsWindow: vi.fn(() => Promise.resolve()), + auxOpenTab: vi.fn(), + confirmDraftAgent: vi.fn(), + openChatModeTab: vi.fn(), + openNewConversationTab: vi.fn(), })) -vi.mock("@/lib/api", () => ({ openSettingsWindow: vi.fn() })) +/** Captured from the (stubbed) FloatingBall: the controller's action handler. */ +let ballAction: ((action: string) => void) | null = null + +vi.mock("@/lib/api", () => ({ openSettingsWindow: spies.openSettingsWindow })) +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) +// The floating ball reads the active ACP connection (to stop a turn) and the +// zoom level. Both pull provider modules this test does not exercise, and the +// ACP context imports the workspace store in a shape the stub below does not +// provide; the ⌘W behaviour under test touches neither. +vi.mock("@/contexts/acp-connections-context", () => ({ + useAcpActions: () => ({ cancel: vi.fn() }), + // Not a zustand store: it returns the context value directly. + useConnectionStore: () => ({ + getActiveKey: () => null, + getConnection: () => null, + }), +})) +vi.mock("@/hooks/use-appearance", () => ({ + useZoomLevel: () => ({ zoomLevel: 100, setZoomLevel: vi.fn() }), + useFloatingBall: () => ({ + floatingBallFavoriteAgents: state.favoriteAgents, + }), +})) +vi.mock("@/hooks/use-sorted-available-agents", () => ({ + useSortedAvailableAgents: () => ({ + sortedTypes: state.sortedAgentTypes, + fresh: true, + refresh: () => Promise.resolve(), + }), +})) +vi.mock("@/components/layout/floating-ball", () => ({ + FloatingBall: ({ onAction }: { onAction: (action: string) => void }) => { + ballAction = onAction + return null + }, +})) vi.mock("@/contexts/active-folder-context", () => ({ - useActiveFolder: () => ({ activeFolder: { id: 1, path: "/repo" } }), + useActiveFolder: () => ({ activeFolder: state.activeFolder }), })) vi.mock("@/hooks/use-is-active-chat-mode", () => ({ useIsActiveChatMode: () => false, @@ -49,24 +101,44 @@ vi.mock("@/contexts/sidebar-context", () => ({ useSidebarContext: () => ({ toggle: vi.fn() }), })) vi.mock("@/contexts/aux-panel-context", () => ({ - useAuxPanelContext: () => ({ toggle: vi.fn() }), + useAuxPanelContext: () => ({ toggle: vi.fn(), openTab: spies.auxOpenTab }), })) vi.mock("@/contexts/terminal-context", () => ({ useTerminalContext: () => ({ toggle: vi.fn() }), })) vi.mock("@/contexts/tab-context", () => ({ useTabActions: () => ({ - openNewConversationTab: vi.fn(), + openNewConversationTab: spies.openNewConversationTab, openTab: vi.fn(), switchTab: spies.switchTab, closeTab: spies.closeTab, + confirmDraftAgent: spies.confirmDraftAgent, + openChatModeTab: spies.openChatModeTab, }), useTabStore: ( - selector: (s: { tabs: { id: string }[]; activeTabId: string | null }) => T + selector: (s: { + tabs: { id: string; conversationId: number | null; agentType: string }[] + activeTabId: string | null + rawTabs: { + id: string + conversationId: number | null + agentType: string + }[] + }) => T ) => selector({ - tabs: state.activeTabId ? [{ id: state.activeTabId }] : [], + tabs: state.activeTabId + ? state.activeTab + ? [state.activeTab] + : [{ id: state.activeTabId, conversationId: 7, agentType: "zcode" }] + : [], activeTabId: state.activeTabId, + rawTabs: + state.activeTabId && state.activeTab + ? [state.activeTab] + : state.activeTabId + ? [{ id: state.activeTabId, conversationId: 7, agentType: "zcode" }] + : [], }), })) vi.mock("@/contexts/workspace-context", () => ({ @@ -90,7 +162,10 @@ vi.mock("@/contexts/workspace-context", () => ({ }), })) vi.mock("@/contexts/workbench-route-context", () => ({ - useWorkbenchRoute: () => ({ openConversations: vi.fn() }), + useWorkbenchRoute: () => ({ + openConversations: vi.fn(), + setRoute: spies.setRoute, + }), })) vi.mock("@/contexts/search-dialog-context", () => ({ useSearchDialog: () => ({ open: false, setOpen: vi.fn() }), @@ -100,7 +175,8 @@ vi.mock("@/hooks/use-shortcut-settings", () => ({ })) vi.mock("@/stores/app-workspace-store", () => ({ isConversationDeleted: () => false, - useAppWorkspaceStore: { getState: () => ({ getFolder: () => null }) }, + useAppWorkspaceStore: (selector: (s: { conversations: [] }) => unknown) => + selector({ conversations: [] }), })) vi.mock("@/lib/closed-tab-stack", () => ({ popClosedTab: () => null })) vi.mock("@/components/conversations/search-command-dialog", () => ({ @@ -167,6 +243,16 @@ beforeEach(() => { return () => state.listeners.delete(event) } ) + spies.setRoute.mockClear() + spies.openSettingsWindow.mockClear() + spies.auxOpenTab.mockClear() + spies.confirmDraftAgent.mockClear() + spies.openChatModeTab.mockClear() + spies.openNewConversationTab.mockClear() + state.activeTab = { id: "conv-1", conversationId: 7, agentType: "zcode" } + state.sortedAgentTypes = ["zcode", "codex"] + state.favoriteAgents = [] + state.activeFolder = { id: 1, path: "/repo" } }) afterEach(() => { @@ -366,3 +452,97 @@ describe("WorkspaceChromeController — ⌘W the page hears", () => { expect(event.defaultPrevented).toBe(false) }) }) + +describe("WorkspaceChromeController — floating-ball actions", () => { + function fire(action: string): void { + render() + expect(typeof ballAction).toBe("function") + ballAction?.(action) + } + + it("routes the workspace actions", async () => { + fire("openTodos") + expect(spies.setRoute).toHaveBeenCalledWith("tasks") + fire("openAutomations") + expect(spies.setRoute).toHaveBeenCalledWith("automations") + fire("openCanvas") + expect(spies.setRoute).toHaveBeenCalledWith("canvas") + fire("openAgents") + await waitFor(() => + expect(spies.openSettingsWindow).toHaveBeenCalledWith("agents") + ) + fire("openSystemAdmin") + await waitFor(() => + expect(spies.openSettingsWindow).toHaveBeenCalledWith("system") + ) + }) + + it("opens the new-to-do composer even when the tasks route is already active", () => { + const received: CustomEvent[] = [] + const listener = (e: Event) => received.push(e as CustomEvent) + window.addEventListener("codeg:create-task-from-text", listener) + fire("openTodosNew") + window.removeEventListener("codeg:create-task-from-text", listener) + expect(spies.setRoute).toHaveBeenCalledWith("tasks") + expect(received).toHaveLength(1) + }) + + it("renames the active tab's conversation by id", () => { + const received: unknown[] = [] + const listener = (e: Event) => received.push((e as CustomEvent).detail) + window.addEventListener("codeg:rename-conversation", listener) + fire("renameConversation") + window.removeEventListener("codeg:rename-conversation", listener) + expect(received).toEqual([{ conversationId: 7 }]) + }) + + it("opens the file tree only with a workspace folder", () => { + fire("openSessionFolder") + expect(spies.auxOpenTab).toHaveBeenCalledWith("file_tree") + + state.activeFolder = null + fire("openSessionFolder") + expect(spies.auxOpenTab).toHaveBeenCalledTimes(1) + }) + + it("cycles the active draft tab's agent and confirms it", () => { + state.activeTab = { + id: "conv-1", + conversationId: null, + agentType: "zcode", + } + fire("cycleAgent") + expect(spies.confirmDraftAgent).toHaveBeenCalledWith("conv-1", "codex") + // The store would have applied the switch; reflect it like the real tab + // row would, so the next stroke continues from codex. + state.activeTab = { + id: "conv-1", + conversationId: null, + agentType: "codex", + } + fire("cycleAgent") + expect(spies.confirmDraftAgent).toHaveBeenLastCalledWith("conv-1", "zcode") + }) + + it("pins a new draft to the next agent when the active tab is saved", () => { + fire("cycleAgent") + expect(spies.confirmDraftAgent).not.toHaveBeenCalled() + expect(spies.openNewConversationTab).toHaveBeenCalledWith( + 1, + "/repo", + expect.objectContaining({ forceAgent: "codex" }) + ) + }) + + it("cycles only within the favorite list for cycleFavoriteAgent", () => { + state.sortedAgentTypes = ["zcode", "codex", "cline"] + state.favoriteAgents = ["codex", "cline"] + fire("cycleFavoriteAgent") + // Current tab agent zcode is not in the list → head of the list. + expect(spies.openNewConversationTab).toHaveBeenCalledWith( + 1, + "/repo", + expect.objectContaining({ forceAgent: "codex" }) + ) + }) +}) diff --git a/src/components/layout/workspace-chrome-controller.tsx b/src/components/layout/workspace-chrome-controller.tsx index 02e8669997..e962696b93 100644 --- a/src/components/layout/workspace-chrome-controller.tsx +++ b/src/components/layout/workspace-chrome-controller.tsx @@ -1,13 +1,34 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" -import { openSettingsWindow } from "@/lib/api" +import { + openSettingsWindow, + openImportSessionsWindow, + updateConversationPinned, +} from "@/lib/api" +import { + useAcpActions, + useConnectionStore, +} from "@/contexts/acp-connections-context" +import { useZoomLevel, useFloatingBall } from "@/hooks/use-appearance" import { useActiveFolder } from "@/contexts/active-folder-context" import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" import { useSidebarContext } from "@/contexts/sidebar-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTerminalContext } from "@/contexts/terminal-context" +import { useSortedAvailableAgents } from "@/hooks/use-sorted-available-agents" import { useTabActions, useTabStore } from "@/contexts/tab-context" +import { requestCreateTaskFromText } from "@/lib/task-compose-events" +import { requestRenameConversation } from "@/lib/rename-conversation-events" +import { + requestDeleteConversation, + requestFocusModeSelector, + requestTogglePlanFull, +} from "@/lib/floating-ball-session-events" +import { + nextRecentConversation, + readRecentSessionDepth, +} from "@/lib/recent-sessions-prefs" import { useWorkspaceActions, useWorkspaceFileTabs, @@ -39,7 +60,30 @@ import { getCurrentWindowLabel } from "@/lib/browser/window-label" import { closeCurrentWindow } from "@/lib/platform" import { getShellTransport, isDesktop } from "@/lib/transport" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" +import { FloatingBall } from "@/components/layout/floating-ball" import { WorkspaceFolderDialog } from "@/components/layout/workspace-folder-dialog" +import { stepZoom, DEFAULT_ZOOM_LEVEL } from "@/lib/theme-presets" +import { toast } from "sonner" +import { useTranslations } from "next-intl" +import type { AgentType } from "@/lib/types" +import type { FloatingBallAction } from "@/lib/floating-ball-gestures" + +/** + * Next entry in a cycling list. With a current agent it picks the following + * one (wrapping); without one — or when the current agent has since dropped + * out of the list — it starts from the head. The favorites list re-reads its + * configured order on every stroke, so cycling there follows what settings + * shows rather than a stale snapshot. + */ +function nextInCycle( + current: AgentType | undefined, + list: AgentType[] +): AgentType { + if (list.length === 0) return current ?? list[0] + if (current == null || !list.includes(current)) return list[0] + const index = list.indexOf(current) + return list[(index + 1) % list.length] +} /** * Headless owner of the workspace's global keyboard shortcuts and the two @@ -50,6 +94,7 @@ import { WorkspaceFolderDialog } from "@/components/layout/workspace-folder-dial * visible bar. Renders no visible chrome — only the dialogs. */ export function WorkspaceChromeController() { + const tBall = useTranslations("FloatingBall") const { activeFolder } = useActiveFolder() const isChatMode = useIsActiveChatMode() const { toggle } = useSidebarContext() @@ -59,6 +104,11 @@ export function WorkspaceChromeController() { useTabActions() const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) + // cycleRecentSessions picks from the localStorage LRU queue; isAvailable + // filters to conversations this window can actually open (cross-window + // pollution guard: the queue is shared, the tabs are per-window). + const conversations = useAppWorkspaceStore((s) => s.conversations) + const rawTabs = useTabStore((s) => s.rawTabs) // Tab-close/navigation shortcuts used to live in the visible tab strips. // Mobile no longer mounts those strips, so this always-mounted controller now // owns them too (see the keydown handler below). @@ -73,8 +123,25 @@ export function WorkspaceChromeController() { openBrowserTab, setActivePane, } = useWorkspaceActions() - const { openConversations } = useWorkbenchRoute() + const { openConversations, setRoute } = useWorkbenchRoute() const { shortcuts } = useShortcutSettings() + const { zoomLevel, setZoomLevel } = useZoomLevel() + // Stopping a turn is the one floating-ball action with no shortcut: the + // composer's stop button is the only other entry point, and it belongs to + // whichever conversation happens to be mounted. The active connection is + // what the workspace considers "the turn in front of the user". + const { cancel } = useAcpActions() + const { getActiveKey, getConnection } = useConnectionStore() + // Agent cycling (floating ball "next agent" / "favorite agents") needs the + // same enabled+available list the draft tabs resolve their defaults from. + const { sortedTypes: sortedAvailableAgents } = useSortedAvailableAgents() + const { confirmDraftAgent, openChatModeTab } = useTabActions() + // Session files ("open the session folder") opens the aux panel's file tree; + // no active folder means there is nothing to reveal (same guard as the + // toggle-files keyboard shortcut below). + const auxPanel = useAuxPanelContext() + const activeTab = tabs.find((tab) => tab.id === activeTabId) + const { floatingBallFavoriteAgents: favoriteAgents } = useFloatingBall() // Search open-state is shared (see search-dialog-context): the trigger lives // in the sidebar, but this always-mounted controller owns the dialog and the // ⌘K shortcut so search works even when the sidebar is collapsed. @@ -206,6 +273,70 @@ export function WorkspaceChromeController() { } }, []) + // Reopen the most recently closed tab, at the slot it was closed from. Shared + // by the shortcut and the floating ball so the two cannot drift. + const reopenClosedTab = useCallback(() => { + // Every entry carries the slot it was closed from, and each opener + // puts the tab back there (clamped to the strip) rather than at the + // end. + while (true) { + const closed = popClosedTab() + if (!closed) return + if (closed.kind === "file") { + void openFilePreview(closed.path, { + folderId: closed.folderId ?? undefined, + index: closed.index, + }) + return + } + if (closed.kind === "browser") { + // Back at the page it was showing; a tab already on that page is + // activated instead (the usual one-tab-per-URL rule). + openBrowserTab(closed.url, { + folderId: closed.folderId ?? undefined, + index: closed.index, + profile: closed.profile, + // Said only when it was remote: otherwise the address decides, + // as it does when the tab comes back after a restart. + ...(closed.remote === true ? { remote: true } : {}), + ...(closed.device ? { device: closed.device } : {}), + }) + return + } + if (closed.conversationId != null) { + // A deletion seen at any point wins. `applyConversationRemove` + // purges what is on the stack when it runs, but a tab that was + // still open then gets recorded afterwards, when the user closes + // it by hand — that entry can only be caught here. + if (isConversationDeleted(closed.conversationId)) continue + openConversations() + openTab( + closed.folderId, + closed.conversationId, + closed.agentType, + closed.isPinned, + closed.title, + { index: closed.index } + ) + return + } + const folder = useAppWorkspaceStore.getState().getFolder(closed.folderId) + const workingDir = closed.workingDir ?? folder?.path + if (!workingDir) continue + openConversations() + openNewConversationTab(closed.folderId, workingDir, { + index: closed.index, + }) + return + } + }, [ + openBrowserTab, + openConversations, + openFilePreview, + openNewConversationTab, + openTab, + ]) + useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (matchShortcutEvent(e, shortcuts.toggle_search)) { @@ -332,61 +463,8 @@ export function WorkspaceChromeController() { if (matchShortcutEvent(e, shortcuts.reopen_last_closed_tab)) { e.preventDefault() - // Every entry carries the slot it was closed from, and each opener - // puts the tab back there (clamped to the strip) rather than at the - // end. - while (true) { - const closed = popClosedTab() - if (!closed) return - if (closed.kind === "file") { - void openFilePreview(closed.path, { - folderId: closed.folderId ?? undefined, - index: closed.index, - }) - return - } - if (closed.kind === "browser") { - // Back at the page it was showing; a tab already on that page is - // activated instead (the usual one-tab-per-URL rule). - openBrowserTab(closed.url, { - folderId: closed.folderId ?? undefined, - index: closed.index, - profile: closed.profile, - // Said only when it was remote: otherwise the address decides, - // as it does when the tab comes back after a restart. - ...(closed.remote === true ? { remote: true } : {}), - ...(closed.device ? { device: closed.device } : {}), - }) - return - } - if (closed.conversationId != null) { - // A deletion seen at any point wins. `applyConversationRemove` - // purges what is on the stack when it runs, but a tab that was - // still open then gets recorded afterwards, when the user closes - // it by hand — that entry can only be caught here. - if (isConversationDeleted(closed.conversationId)) continue - openConversations() - openTab( - closed.folderId, - closed.conversationId, - closed.agentType, - closed.isPinned, - closed.title, - { index: closed.index } - ) - return - } - const folder = useAppWorkspaceStore - .getState() - .getFolder(closed.folderId) - const workingDir = closed.workingDir ?? folder?.path - if (!workingDir) continue - openConversations() - openNewConversationTab(closed.folderId, workingDir, { - index: closed.index, - }) - return - } + reopenClosedTab() + return } } document.addEventListener("keydown", handleKeyDown) @@ -410,6 +488,7 @@ export function WorkspaceChromeController() { activeTabId, switchTab, closeCurrentTab, + reopenClosedTab, mode, activePane, filesMaximized, @@ -419,10 +498,279 @@ export function WorkspaceChromeController() { switchFileTab, ]) + const handleFloatingBallAction = useCallback( + (action: FloatingBallAction) => { + switch (action) { + case "newConversation": + if (!activeFolder) return + openConversations() + openNewConversationTab(activeFolder.id, activeFolder.path) + return + case "toggleSidebar": + toggle() + return + case "toggleAuxPanel": + toggleAuxPanel() + return + case "toggleSearch": + setSearchOpen((prev) => !prev) + return + case "toggleTerminal": + toggleTerminal() + return + case "nextTab": + case "prevTab": { + if (tabs.length < 2 || !activeTabId) return + const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId) + if (currentIndex === -1) return + const offset = action === "nextTab" ? 1 : -1 + const nextIndex = (currentIndex + offset + tabs.length) % tabs.length + switchTab(tabs[nextIndex].id) + return + } + case "reopenClosedTab": + reopenClosedTab() + return + case "openFolder": + void handleOpenFolder() + return + case "zoomIn": + setZoomLevel(stepZoom(zoomLevel, 1)) + return + case "zoomOut": + setZoomLevel(stepZoom(zoomLevel, -1)) + return + case "zoomReset": + setZoomLevel(DEFAULT_ZOOM_LEVEL) + return + case "stopGenerating": { + const key = getActiveKey() + if (!key) return + void cancel(key).catch((err) => { + console.error("[WorkspaceChromeController] stop failed:", err) + }) + return + } + case "openSettings": + handleOpenSettings() + return + case "openTodos": + setRoute("tasks") + return + case "openTodosNew": + // Park the draft before switching — the Tasks page consumes it on + // mount, and on the live event when the route is already active. + requestCreateTaskFromText({ + text: "", + folderId: activeFolder?.id ?? null, + }) + setRoute("tasks") + return + case "openAutomations": + setRoute("automations") + return + case "openCanvas": + setRoute("canvas") + return + case "openAgents": + void openSettingsWindow("agents").catch((err) => { + console.error("[WorkspaceChromeController] agents settings:", err) + }) + return + case "openSystemAdmin": + void openSettingsWindow("system").catch((err) => { + console.error("[WorkspaceChromeController] system settings:", err) + }) + return + case "renameConversation": { + const id = activeTab?.conversationId + if (id == null) { + toast.info(tBall("toasts.renameNeedsSavedSession")) + return + } + requestRenameConversation({ conversationId: id }) + return + } + case "openSessionFolder": + if (!activeFolder) { + toast.info(tBall("toasts.sessionFolderNeedsWorkspace")) + return + } + auxPanel.openTab("file_tree") + return + case "openSessionDetails": + auxPanel.openTab("session_details") + return + case "openChanges": + if (!activeFolder) { + toast.info(tBall("toasts.sessionFolderNeedsWorkspace")) + return + } + auxPanel.openTab("changes") + return + case "openGitLog": + if (!activeFolder) { + toast.info(tBall("toasts.sessionFolderNeedsWorkspace")) + return + } + auxPanel.openTab("git_log") + return + case "cycleAgent": + case "cycleFavoriteAgent": { + const list = + action === "cycleAgent" + ? sortedAvailableAgents + : sortedAvailableAgents.filter((a) => favoriteAgents.includes(a)) + if (list.length <= 1) { + toast.info(tBall("toasts.cycleNeedsMoreAgents")) + return + } + const tab = activeTab + // The switch retargets the draft's ACP connection via + // useConnectionLifecycle; a persisted conversation's agent is fixed, + // so open a fresh draft pinned to the next agent instead. + if (!tab || tab.conversationId != null) { + const next = nextInCycle(tab?.agentType, list) + if (activeFolder) { + openNewConversationTab(activeFolder.id, activeFolder.path, { + forceAgent: next, + }) + } else { + openChatModeTab({ forceAgent: next }) + } + return + } + const next = nextInCycle(tab.agentType, list) + confirmDraftAgent(tab.id, next) + return + } + case "openBallBindings": + void openSettingsWindow("appearance").catch((err) => { + console.error( + "[WorkspaceChromeController] appearance settings:", + err + ) + }) + return + case "openCollaboration": + void openSettingsWindow("collaboration").catch((err) => { + console.error( + "[WorkspaceChromeController] collaboration settings:", + err + ) + }) + return + case "openImportSessions": + void openImportSessionsWindow().catch((err) => { + console.error("[WorkspaceChromeController] import sessions:", err) + }) + return + case "deleteCurrentConversation": + requestDeleteConversation(activeTab?.conversationId ?? null) + return + case "togglePinConversation": { + const conv = conversations.find( + (c) => c.id === activeTab?.conversationId + ) + if (!conv) { + toast.info(tBall("toasts.renameNeedsSavedSession")) + return + } + void updateConversationPinned(conv.id, conv.pinned_at == null) + .then(() => toast.info(tBall("toasts.pinToggled"))) + .catch((err) => { + console.error("[WorkspaceChromeController] pin toggle:", err) + }) + return + } + case "focusModeSelector": + requestFocusModeSelector(activeTab?.conversationId ?? null) + return + case "togglePlanFullAccess": + requestTogglePlanFull(activeTab?.conversationId ?? null) + return + case "cycleRunningSessions": { + // "Running" = this window's connections currently generating. The + // connection store is per-window, so a session streaming in another + // window is invisible here — the toast phrasing accounts for that. + const runningTabs = rawTabs.filter( + (tab) => getConnection(tab.id)?.status === "prompting" + ) + if (runningTabs.length === 0) { + toast.info(tBall("toasts.noRunningSession")) + return + } + if (runningTabs.length <= 1) { + toast.info(tBall("toasts.noOtherRunning")) + return + } + const currentIndex = runningTabs.findIndex( + (tab) => tab.id === activeTabId + ) + // Active tab not running → jump to the first running tab; otherwise + // step forward through the running set in tab order. + const nextIndex = (currentIndex + 1) % runningTabs.length + switchTab(runningTabs[nextIndex].id) + return + } + case "cycleRecentSessions": { + if (!activeFolder) return + const target = nextRecentConversation( + activeTab?.conversationId ?? null, + readRecentSessionDepth(), + (id) => + conversations.some((c) => c.id === id) && + rawTabs.some((tab) => tab.conversationId === id) + ) + if (target == null) { + toast.info(tBall("toasts.noOtherRecent")) + return + } + const tab = rawTabs.find((t) => t.conversationId === target) + if (tab) switchTab(tab.id) + return + } + case "panel": + return + } + }, + [ + activeFolder, + activeTab, + activeTabId, + auxPanel, + cancel, + confirmDraftAgent, + conversations, + favoriteAgents, + getActiveKey, + getConnection, + handleOpenFolder, + handleOpenSettings, + openChatModeTab, + openConversations, + openNewConversationTab, + rawTabs, + reopenClosedTab, + setSearchOpen, + setRoute, + setZoomLevel, + sortedAvailableAgents, + switchTab, + tBall, + tabs, + toggle, + toggleAuxPanel, + toggleTerminal, + zoomLevel, + ] + ) + return ( <> + ) } diff --git a/src/components/settings/appearance-settings.tsx b/src/components/settings/appearance-settings.tsx index 405409c3e0..1527a25a56 100644 --- a/src/components/settings/appearance-settings.tsx +++ b/src/components/settings/appearance-settings.tsx @@ -5,6 +5,7 @@ import { Monitor, Moon, MoveHorizontal, + Orbit, Sparkles, Sun, Type, @@ -29,7 +30,15 @@ import { useWelcomeQuickActions, useChatAnimationsSetting, useChatContentWidth, + useFloatingBall, } from "@/hooks/use-appearance" +import { useAcpAgents } from "@/hooks/use-acp-agents" +import { + DEFAULT_FLOATING_BALL_BINDINGS, + FLOATING_BALL_GESTURES, + type FloatingBallAction, +} from "@/lib/floating-ball-gestures" +import { type FloatingBallSize } from "@/components/appearance-provider" import { CHAT_CONTENT_DEFAULT, CHAT_CONTENT_MIN, @@ -44,6 +53,11 @@ import { type ThemeColor, type ZoomLevel, } from "@/lib/theme-presets" +import { + readRecentSessionDepth, + writeRecentSessionDepth, + type RecentSessionDepth, +} from "@/lib/recent-sessions-prefs" import { PetManagerSection } from "./pet-manager-section" import { FontSettingsSection } from "./font-settings-section" import { WorkspaceBackgroundSection } from "./workspace-background-section" @@ -60,6 +74,112 @@ function readScreenWidth(): number { type ThemeMode = "system" | "light" | "dark" +/** Actions the binding dropdown offers (everything but the panel toggle). */ +const BALL_BINDABLE_ACTIONS: readonly FloatingBallAction[] = [ + "panel", + "newConversation", + "toggleSidebar", + "toggleAuxPanel", + "toggleSearch", + "toggleTerminal", + "nextTab", + "prevTab", + "reopenClosedTab", + "openFolder", + "openTodos", + "openTodosNew", + "openAutomations", + "openCanvas", + "openAgents", + "openSystemAdmin", + "openSessionDetails", + "openChanges", + "openGitLog", + "renameConversation", + "openSessionFolder", + "cycleAgent", + "cycleFavoriteAgent", + "openBallBindings", + "cycleRunningSessions", + "cycleRecentSessions", + "deleteCurrentConversation", + "openImportSessions", + "togglePinConversation", + "focusModeSelector", + "togglePlanFullAccess", + "openCollaboration", + "zoomIn", + "zoomOut", + "zoomReset", + "stopGenerating", + "openSettings", +] + +function isFloatingBallSize(v: string): v is FloatingBallSize { + return v === "small" || v === "medium" || v === "large" +} + +/** Label of a gesture's built-in default, for the placeholder row. */ +function defaultGestureLabel( + action: FloatingBallAction, + // Loosely typed: callers pass the FloatingBall namespace's t; next-intl's + // key union would force a per-key overload for no safety gain here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t: any +): string { + return action === "panel" ? t("panelTitle") : t(`actions.${action}`) +} + +/** Multi-select (2-3) of agents the cycle-favorite gesture switches between. */ +function FavoriteAgentsPicker() { + const t = useTranslations("FloatingBall") + const { floatingBallFavoriteAgents, setFloatingBallFavoriteAgents } = + useFloatingBall() + const { agents } = useAcpAgents() + const usable = agents.filter((a) => a.enabled && a.available) + + const toggle = (agentType: string) => { + const has = floatingBallFavoriteAgents.includes(agentType) + if (has) { + setFloatingBallFavoriteAgents( + floatingBallFavoriteAgents.filter((a) => a !== agentType) + ) + return + } + // Cap at 3: the cycle is a thumb gesture, a longer loop defeats it. + if (floatingBallFavoriteAgents.length >= 3) return + setFloatingBallFavoriteAgents([...floatingBallFavoriteAgents, agentType]) + } + + return ( +
+ {usable.map((agent) => { + const picked = floatingBallFavoriteAgents.includes(agent.agent_type) + return ( + + ) + })} + {usable.length === 0 && ( + + {t("favoriteAgentsEmpty")} + + )} +
+ ) +} + export function AppearanceSettings() { const t = useTranslations("AppearanceSettings") const { theme, resolvedTheme, setTheme } = useTheme() @@ -68,12 +188,32 @@ export function AppearanceSettings() { const { showWelcomeQuickActions, setShowWelcomeQuickActions } = useWelcomeQuickActions() const { chatAnimations, setChatAnimations } = useChatAnimationsSetting() + const { + floatingBallEnabled, + setFloatingBallEnabled, + floatingBallPosition, + setFloatingBallPosition, + floatingBallBindings, + setFloatingBallBindings, + floatingBallSize, + setFloatingBallSize, + } = useFloatingBall() const { chatContentWidth, setChatContentWidth } = useChatContentWidth() const [screenWidth] = useState(readScreenWidth) // 宽度以 100% 缩放下的 px 计(随缩放一起放大),所以同一块屏幕在高缩放下能放下 // 的宽度更小。 const chatWidthMax = chatContentWidthCeiling(screenWidth, zoomLevel / 100) + // Gesture/action labels come from the FloatingBall namespace — the only one + // that actually carries translations; the AppearanceSettings.gestures/actions + // mirror is legacy and no longer read here. + const ballT = useTranslations("FloatingBall") + const [recentSessionDepth, setRecentSessionDepth] = useState( + readRecentSessionDepth + ) + const actionLabel = (action: FloatingBallAction) => + action === "panel" ? ballT("panelTitle") : ballT(`actions.${action}`) + const resolvedThemeLabel = resolvedTheme === "dark" ? t("resolvedTheme.dark") @@ -295,6 +435,177 @@ export function AppearanceSettings() { + {/* ===== Floating ball ===== */} +
+
+ +

+ {t("floatingBall.sectionTitle")} +

+
+ +

+ {t("floatingBall.sectionDescription")} +

+ + + +
+

+ {t("floatingBall.hint")} +

+ +
+ + {/* Ball size: three presets, defaults to large (the v81 size). */} +
+

{t("floatingBall.ballSize")}

+ +
+ + {/* Per-gesture binding overrides. An empty SelectValue means the + gesture falls back to the built-in default row below. */} +
+

+ {t("floatingBall.gestureBindings")} +

+
+ {FLOATING_BALL_GESTURES.filter( + (g) => g !== "tap" && g !== "doubleTap" && g !== "longPress" + ).map((gesture) => { + const override = floatingBallBindings[gesture] + const value = override ?? "" + return ( +
+ + {ballT(`gestures.${gesture}`, { + fallback: gesture, + })} + + +
+ ) + })} +
+
+ + {/* Favorite agents for the cycle-favorite gesture. */} +
+

+ {t("floatingBall.favoriteAgents")} +

+ +

+ {t("floatingBall.favoriteAgentsHint")} +

+
+ + {/* Depth of the recent-sessions cycle (2 = toggle between the two + most recently opened). Read/written straight to localStorage — + the writer lives in tab-store, outside React. */} +
+

{ballT("recentSessionDepth")}

+ +
+
{/* ===== Chat content width ===== */}
diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts index cb354492cc..ac2730b1cf 100644 --- a/src/hooks/use-appearance.ts +++ b/src/hooks/use-appearance.ts @@ -4,6 +4,7 @@ import { useContext } from "react" import { AppearanceContext, ChatAnimationsContext, + DEFAULT_FLOATING_BALL_SIZE, } from "@/components/appearance-provider" import { applyChatContentWidth, @@ -45,6 +46,27 @@ export function useChatAnimationsSetting() { return { chatAnimations, setChatAnimations } } +/** + * 悬浮球开关与停靠位置。同样允许在 Provider 之外降级:球只在工作区里渲染, + * 设置页是另一个窗口,那里只读开关、不渲染球。 + */ +export function useFloatingBall() { + const ctx = useContext(AppearanceContext) + return { + floatingBallEnabled: ctx?.floatingBallEnabled ?? false, + setFloatingBallEnabled: ctx?.setFloatingBallEnabled ?? (() => {}), + floatingBallPosition: ctx?.floatingBallPosition ?? null, + setFloatingBallPosition: ctx?.setFloatingBallPosition ?? (() => {}), + floatingBallBindings: ctx?.floatingBallBindings ?? {}, + setFloatingBallBindings: ctx?.setFloatingBallBindings ?? (() => {}), + floatingBallSize: ctx?.floatingBallSize ?? DEFAULT_FLOATING_BALL_SIZE, + setFloatingBallSize: ctx?.setFloatingBallSize ?? (() => {}), + floatingBallFavoriteAgents: ctx?.floatingBallFavoriteAgents ?? [], + setFloatingBallFavoriteAgents: + ctx?.setFloatingBallFavoriteAgents ?? (() => {}), + } +} + /** * 聊天区域是否播放动画。供 JS 驱动的动画(Shimmer、平滑滚动)读取; * 在 Provider 之外(测试、独立窗口)回退为开启,不抛错。读的是只装这一个布尔值的 diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3b3e49f55d..c9ec45f135 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -239,6 +239,88 @@ "appliedToast": "تم تطبيق الخلفية", "credit": "الصور من wallhaven.cc · محتوى آمن فقط" } + }, + "actions": { + "newConversation": "محادثة جديدة", + "toggleSidebar": "تبديل الشريط الجانبي", + "toggleSearch": "بحث", + "toggleTerminal": "تبديل الطرفية", + "nextTab": "التبويب التالي", + "prevTab": "التبويب السابق", + "reopenClosedTab": "إعادة فتح التبويب المغلق", + "openFolder": "فتح مجلد", + "zoomIn": "تكبير", + "zoomOut": "تصغير", + "zoomReset": "إعادة تعيين التكبير", + "stopGenerating": "إيقاف التوليد", + "openSettings": "الإعدادات", + "openTodos": "المهام", + "openTodosNew": "مهمة جديدة", + "openAutomations": "الأتمتة", + "openCanvas": "اللوحة", + "openAgents": "الوكلاء", + "openSystemAdmin": "إدارة النظام", + "renameConversation": "إعادة تسمية المحادثة", + "openSessionFolder": "ملفات الجلسة", + "cycleAgent": "الوكيل التالي", + "cycleFavoriteAgent": "الوكلاء المفضلون", + "openBallBindings": "ربط الإيماءات", + "cycleRunningSessions": "الجلسات الجارية", + "cycleRecentSessions": "الجلسات الأخيرة", + "deleteCurrentConversation": "حذف الجلسة الحالية", + "openImportSessions": "استيراد الجلسات", + "togglePinConversation": "تثبيت / إلغاء التثبيت", + "focusModeSelector": "محدد الوضع", + "togglePlanFullAccess": "خطة / وصول كامل", + "openCollaboration": "التعاون" + }, + "gestures": { + "tap": "نقرة", + "doubleTap": "نقرة مزدوجة", + "longPress": "ضغط مطول", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ ثم ←", + "upThenRight": "↑ ثم →", + "upDown": "↑ ثم ↓", + "leftThenRight": "← ثم →", + "rightThenLeft": "→ ثم ←", + "upLeftThenDown": "↖ ثم ↓", + "upRightThenDown": "↗ ثم ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ ثم ↑", + "upThenDownRight": "↑ ثم ↘", + "downThenUpRight": "↓ ثم ↗", + "rightThenUp": "→ ثم ↑", + "rightThenDown": "→ ثم ↓", + "downThenRight": "↓ ثم →", + "downThenLeft": "↓ ثم ←", + "downRightThenUp": "↘ ثم ↑", + "downLeftThenUp": "↙ ثم ↑", + "upLeftThenRight": "↖ ثم →", + "upRightThenLeft": "↗ ثم ←" + }, + "floatingBall": { + "sectionTitle": "الكرة العائمة", + "sectionDescription": "كرة قابلة للسحب فوق مساحة العمل تحوّل السحب إلى اختصارات، مصممة للهاتف حيث لا توجد لوحة مفاتيح.", + "enable": "إظهار الكرة العائمة", + "hint": "اسحب للتحريك، اسحب بسرعة لإيماءة، اضغط مطوّلاً للوحة وقائمة الإيماءات.", + "resetPosition": "إعادة تعيين الموضع", + "ballSize": "حجم الكرة", + "sizeSmall": "صغير", + "sizeMedium": "متوسط", + "sizeLarge": "كبير", + "gestureBindings": "ربط الإيماءات", + "bindingCleared": "غير مربوط", + "favoriteAgents": "الوكلاء المفضلون", + "favoriteAgentsHint": "اختر 2-3 وكلاء للتنقل بينهم بإيماءة المفضلين.", + "favoriteAgentsEmpty": "لا يوجد وكلاء متاحون", + "panelTitle": "إجراءات سريعة" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "تعذّر على المتصفح المدمج التأكد من أنه يصل إلى المضيف البعيد عبر النفق. حاول مرة أخرى بعد قليل." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "محادثة جديدة", + "toggleSidebar": "تبديل الشريط الجانبي", + "toggleAuxPanel": "تبديل اللوحة اليمنى", + "toggleSearch": "بحث", + "toggleTerminal": "تبديل الطرفية", + "nextTab": "التبويب التالي", + "prevTab": "التبويب السابق", + "reopenClosedTab": "إعادة فتح التبويب المغلق", + "openFolder": "فتح مجلد", + "zoomIn": "تكبير", + "zoomOut": "تصغير", + "zoomReset": "إعادة تعيين التكبير", + "stopGenerating": "إيقاف التوليد", + "openSettings": "الإعدادات", + "openTodos": "المهام", + "openTodosNew": "مهمة جديدة", + "openAutomations": "الأتمتة", + "openCanvas": "اللوحة", + "openAgents": "الوكلاء", + "openSystemAdmin": "إدارة النظام", + "openSessionDetails": "تفاصيل الجلسة", + "openChanges": "تغييرات Git", + "openGitLog": "الالتزامات", + "renameConversation": "إعادة تسمية المحادثة", + "openSessionFolder": "ملفات الجلسة", + "cycleAgent": "الوكيل التالي", + "cycleFavoriteAgent": "الوكلاء المفضلون", + "openBallBindings": "ربط الإيماءات", + "cycleRunningSessions": "الجلسات الجارية", + "cycleRecentSessions": "الجلسات الأخيرة", + "deleteCurrentConversation": "حذف الجلسة الحالية", + "openImportSessions": "استيراد الجلسات", + "togglePinConversation": "تثبيت / إلغاء التثبيت", + "focusModeSelector": "محدد الوضع", + "togglePlanFullAccess": "خطة / وصول كامل", + "openCollaboration": "التعاون" + }, + "gestures": { + "tap": "نقرة", + "doubleTap": "نقرة مزدوجة", + "longPress": "ضغط مطول", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ ثم ←", + "upThenRight": "↑ ثم →", + "upDown": "↑ ثم ↓", + "leftThenRight": "← ثم →", + "rightThenLeft": "→ ثم ←", + "upLeftThenDown": "↖ ثم ↓", + "upRightThenDown": "↗ ثم ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ ثم ↑", + "upThenDownRight": "↑ ثم ↘", + "downThenUpRight": "↓ ثم ↗", + "rightThenUp": "→ ثم ↑", + "rightThenDown": "→ ثم ↓", + "downThenRight": "↓ ثم →", + "downThenLeft": "↓ ثم ←", + "downRightThenUp": "↘ ثم ↑", + "downLeftThenUp": "↙ ثم ↑", + "upLeftThenRight": "↖ ثم →", + "upRightThenLeft": "↗ ثم ←" + }, + "toasts": { + "renameNeedsSavedSession": "إعادة التسمية تعمل على الجلسات المحفوظة فقط — أرسل رسالة أولاً.", + "sessionFolderNeedsWorkspace": "افتح مجلد مساحة العمل أولاً.", + "cycleNeedsMoreAgents": "وكيل واحد متاح فقط — لا يوجد ما يمكن التبديل إليه.", + "noOtherRunning": "لا توجد جلسة جارية أخرى.", + "noOtherRecent": "لا توجد جلسة حديثة أخرى.", + "noPlanMode": "لا يحتوي هذا الوكيل على وضع الخطة.", + "pinToggled": "تم تحديث حالة التثبيت.", + "noRunningSession": "لا توجد جلسة جارية بعد.", + "noModesAvailable": "لا يوفر هذا الوكيل أي أوضاع.", + "noSwitchTarget": "لا يوجد وضع للتبديل إليه (لم يتم العثور على وضع Plan أو الوصول الكامل)." + }, + "label": "الكرة العائمة", + "panelTitle": "إجراءات سريعة", + "settingsTitle": "الكرة العائمة", + "close": "إغلاق", + "resetPosition": "إعادة تعيين الموضع", + "cheatSheetTitle": "الإيماءات", + "hint": "اسحب الكرة لتحريكها. السحب السريع يشغّل إيماءة، والضغط المطوّل يفتح هذه اللوحة.", + "favoriteAgentsEmpty": "لا يوجد وكلاء متاحون", + "recentSessionDepth": "عمق تبديل الجلسات الأخيرة", + "bindingCleared": "غير مربوط" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 1b05271eb0..8140cf4096 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -239,6 +239,88 @@ "appliedToast": "Hintergrund angewendet", "credit": "Bilder von wallhaven.cc · nur SFW" } + }, + "actions": { + "newConversation": "Neue Unterhaltung", + "toggleSidebar": "Seitenleiste umschalten", + "toggleSearch": "Suchen", + "toggleTerminal": "Terminal umschalten", + "nextTab": "Nächster Tab", + "prevTab": "Vorheriger Tab", + "reopenClosedTab": "Geschlossenen Tab wieder öffnen", + "openFolder": "Ordner öffnen", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "zoomReset": "Zoom zurücksetzen", + "stopGenerating": "Generierung stoppen", + "openSettings": "Einstellungen", + "openTodos": "Aufgaben", + "openTodosNew": "Neue Aufgabe", + "openAutomations": "Automatisierungen", + "openCanvas": "Canvas", + "openAgents": "Agenten", + "openSystemAdmin": "Systemverwaltung", + "renameConversation": "Unterhaltung umbenennen", + "openSessionFolder": "Sitzungsdateien", + "cycleAgent": "Nächster Agent", + "cycleFavoriteAgent": "Favoriten-Agenten", + "openBallBindings": "Gesten-Zuordnung", + "cycleRunningSessions": "Laufende Sitzungen", + "cycleRecentSessions": "Letzte Sitzungen", + "deleteCurrentConversation": "Aktuelle Sitzung löschen", + "openImportSessions": "Sitzungen importieren", + "togglePinConversation": "Anpinnen / lösen", + "focusModeSelector": "Modus-Auswahl", + "togglePlanFullAccess": "Plan / Vollzugriff", + "openCollaboration": "Kollaboration" + }, + "gestures": { + "tap": "Tippen", + "doubleTap": "Doppelt tippen", + "longPress": "Lange drücken", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ dann ←", + "upThenRight": "↑ dann →", + "upDown": "↑ dann ↓", + "leftThenRight": "← dann →", + "rightThenLeft": "→ dann ←", + "upLeftThenDown": "↖ dann ↓", + "upRightThenDown": "↗ dann ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ dann ↑", + "upThenDownRight": "↑ dann ↘", + "downThenUpRight": "↓ dann ↗", + "rightThenUp": "→ dann ↑", + "rightThenDown": "→ dann ↓", + "downThenRight": "↓ dann →", + "downThenLeft": "↓ dann ←", + "downRightThenUp": "↘ dann ↑", + "downLeftThenUp": "↙ dann ↑", + "upLeftThenRight": "↖ dann →", + "upRightThenLeft": "↗ dann ←" + }, + "floatingBall": { + "sectionTitle": "Schwebekugel", + "sectionDescription": "Eine verschiebbare Kugel über dem Arbeitsbereich, die Wischgesten in Tastenkürzel verwandelt – gedacht für das Telefon ohne Tastatur.", + "enable": "Schwebekugel anzeigen", + "hint": "Ziehen zum Verschieben, schnelles Wischen für eine Geste, langes Drücken für Panel und Gestenübersicht.", + "resetPosition": "Position zurücksetzen", + "ballSize": "Kugelgröße", + "sizeSmall": "Klein", + "sizeMedium": "Mittel", + "sizeLarge": "Groß", + "gestureBindings": "Gestenzuordnung", + "bindingCleared": "Nicht zugewiesen", + "favoriteAgents": "Favoriten-Agenten", + "favoriteAgentsHint": "Wähle 2-3 Agenten, um sie mit dem Favoriten-Gest zu durchlaufen.", + "favoriteAgentsEmpty": "Keine Agenten verfügbar", + "panelTitle": "Schnellaktionen" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "Der integrierte Browser konnte nicht bestätigen, dass er den entfernten Host über den Tunnel erreicht. Versuche es gleich noch einmal." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "Neue Unterhaltung", + "toggleSidebar": "Seitenleiste umschalten", + "toggleAuxPanel": "Rechtes Panel umschalten", + "toggleSearch": "Suchen", + "toggleTerminal": "Terminal umschalten", + "nextTab": "Nächster Tab", + "prevTab": "Vorheriger Tab", + "reopenClosedTab": "Geschlossenen Tab wieder öffnen", + "openFolder": "Ordner öffnen", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "zoomReset": "Zoom zurücksetzen", + "stopGenerating": "Generierung stoppen", + "openSettings": "Einstellungen", + "openTodos": "Aufgaben", + "openTodosNew": "Neue Aufgabe", + "openAutomations": "Automatisierungen", + "openCanvas": "Canvas", + "openAgents": "Agenten", + "openSystemAdmin": "Systemverwaltung", + "openSessionDetails": "Sitzungsdetails", + "openChanges": "Git-Änderungen", + "openGitLog": "Commits", + "renameConversation": "Unterhaltung umbenennen", + "openSessionFolder": "Sitzungsdateien", + "cycleAgent": "Nächster Agent", + "cycleFavoriteAgent": "Favoriten-Agenten", + "openBallBindings": "Gesten-Zuordnung", + "cycleRunningSessions": "Laufende Sitzungen", + "cycleRecentSessions": "Letzte Sitzungen", + "deleteCurrentConversation": "Aktuelle Sitzung löschen", + "openImportSessions": "Sitzungen importieren", + "togglePinConversation": "Anpinnen / lösen", + "focusModeSelector": "Modus-Auswahl", + "togglePlanFullAccess": "Plan / Vollzugriff", + "openCollaboration": "Kollaboration" + }, + "gestures": { + "tap": "Tippen", + "doubleTap": "Doppelt tippen", + "longPress": "Lange drücken", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ dann ←", + "upThenRight": "↑ dann →", + "upDown": "↑ dann ↓", + "leftThenRight": "← dann →", + "rightThenLeft": "→ dann ←", + "upLeftThenDown": "↖ dann ↓", + "upRightThenDown": "↗ dann ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ dann ↑", + "upThenDownRight": "↑ dann ↘", + "downThenUpRight": "↓ dann ↗", + "rightThenUp": "→ dann ↑", + "rightThenDown": "→ dann ↓", + "downThenRight": "↓ dann →", + "downThenLeft": "↓ dann ←", + "downRightThenUp": "↘ dann ↑", + "downLeftThenUp": "↙ dann ↑", + "upLeftThenRight": "↖ dann →", + "upRightThenLeft": "↗ dann ←" + }, + "toasts": { + "renameNeedsSavedSession": "Umbenennen gilt nur für gespeicherte Sitzungen – senden Sie zuerst eine Nachricht.", + "sessionFolderNeedsWorkspace": "Öffnen Sie zuerst einen Arbeitsbereich-Ordner.", + "cycleNeedsMoreAgents": "Nur ein Agent verfügbar – nichts zum Wechseln.", + "noOtherRunning": "Keine weitere laufende Sitzung.", + "noOtherRecent": "Keine weitere letzte Sitzung.", + "noPlanMode": "Dieser Agent hat keinen Plan-Modus.", + "pinToggled": "Pin-Status aktualisiert.", + "noRunningSession": "Noch keine laufende Sitzung.", + "noModesAvailable": "Dieser Agent stellt keine Modi bereit.", + "noSwitchTarget": "Kein Modus zum Wechseln gefunden (Plan- oder Vollzugriffsmodus fehlt)." + }, + "label": "Schwebekugel", + "panelTitle": "Schnellaktionen", + "settingsTitle": "Schwebekugel", + "close": "Schließen", + "resetPosition": "Position zurücksetzen", + "cheatSheetTitle": "Gesten", + "hint": "Ziehe die Kugel, um sie zu verschieben. Schnelles Wischen löst eine Geste aus, langes Drücken öffnet dieses Panel.", + "favoriteAgentsEmpty": "Keine Agenten verfügbar", + "recentSessionDepth": "Umschalttiefe letzte Sitzungen", + "bindingCleared": "Nicht zugewiesen" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 35288fef1b..3c0fc61f03 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -239,6 +239,88 @@ "appliedToast": "Wallpaper applied", "credit": "Images from wallhaven.cc · SFW only" } + }, + "actions": { + "newConversation": "New conversation", + "toggleSidebar": "Toggle sidebar", + "toggleSearch": "Search", + "toggleTerminal": "Toggle terminal", + "nextTab": "Next tab", + "prevTab": "Previous tab", + "reopenClosedTab": "Reopen closed tab", + "openFolder": "Open folder", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "zoomReset": "Reset zoom", + "stopGenerating": "Stop generating", + "openSettings": "Settings", + "openTodos": "To-dos", + "openTodosNew": "New to-do", + "openAutomations": "Automations", + "openCanvas": "Canvas", + "openAgents": "Agents", + "openSystemAdmin": "System admin", + "renameConversation": "Rename conversation", + "openSessionFolder": "Session files", + "cycleAgent": "Next agent", + "cycleFavoriteAgent": "Favorite agents", + "openBallBindings": "Gesture bindings", + "cycleRunningSessions": "Running sessions", + "cycleRecentSessions": "Recent sessions", + "deleteCurrentConversation": "Delete current session", + "openImportSessions": "Import sessions", + "togglePinConversation": "Pin / unpin session", + "focusModeSelector": "Mode selector", + "togglePlanFullAccess": "Plan / full access", + "openCollaboration": "Collaboration" + }, + "gestures": { + "tap": "Tap", + "doubleTap": "Double tap", + "longPress": "Long press", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ then ←", + "upThenRight": "↑ then →", + "upDown": "↑ then ↓", + "leftThenRight": "← then →", + "rightThenLeft": "→ then ←", + "upLeftThenDown": "↖ then ↓", + "upRightThenDown": "↗ then ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ then ↑", + "upThenDownRight": "↑ then ↘", + "downThenUpRight": "↓ then ↗", + "rightThenUp": "→ then ↑", + "rightThenDown": "→ then ↓", + "downThenRight": "↓ then →", + "downThenLeft": "↓ then ←", + "downRightThenUp": "↘ then ↑", + "downLeftThenUp": "↙ then ↑", + "upLeftThenRight": "↖ then →", + "upRightThenLeft": "↗ then ←" + }, + "floatingBall": { + "sectionTitle": "Floating Ball", + "sectionDescription": "A draggable ball over the workspace that turns swipes into shortcuts — built for the phone, where there is no keyboard.", + "enable": "Show floating ball", + "hint": "Drag to move; swipe quickly for a gesture; long press for the panel and cheat sheet.", + "resetPosition": "Reset position", + "ballSize": "Ball size", + "sizeSmall": "Small", + "sizeMedium": "Medium", + "sizeLarge": "Large", + "gestureBindings": "Gesture bindings", + "bindingCleared": "Unbound", + "favoriteAgents": "Favorite agents", + "favoriteAgentsHint": "Pick 2-3 agents to cycle through with the favorite-agents gesture.", + "favoriteAgentsEmpty": "No available agents", + "panelTitle": "Quick actions" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "The built-in browser couldn't confirm that it reaches the remote host through the tunnel. Try again in a moment." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "New conversation", + "toggleSidebar": "Toggle sidebar", + "toggleAuxPanel": "Toggle right panel", + "toggleSearch": "Search", + "toggleTerminal": "Toggle terminal", + "nextTab": "Next tab", + "prevTab": "Previous tab", + "reopenClosedTab": "Reopen closed tab", + "openFolder": "Open folder", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "zoomReset": "Reset zoom", + "stopGenerating": "Stop generating", + "openSettings": "Settings", + "openTodos": "To-dos", + "openTodosNew": "New to-do", + "openAutomations": "Automations", + "openCanvas": "Canvas", + "openAgents": "Agents", + "openSystemAdmin": "System admin", + "openSessionDetails": "Session details", + "openChanges": "Git changes", + "openGitLog": "Commits", + "renameConversation": "Rename conversation", + "openSessionFolder": "Session files", + "cycleAgent": "Next agent", + "cycleFavoriteAgent": "Favorite agents", + "openBallBindings": "Gesture bindings", + "cycleRunningSessions": "Running sessions", + "cycleRecentSessions": "Recent sessions", + "deleteCurrentConversation": "Delete current session", + "openImportSessions": "Import sessions", + "togglePinConversation": "Pin / unpin session", + "focusModeSelector": "Mode selector", + "togglePlanFullAccess": "Plan / full access", + "openCollaboration": "Collaboration" + }, + "gestures": { + "tap": "Tap", + "doubleTap": "Double tap", + "longPress": "Long press", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ then ←", + "upThenRight": "↑ then →", + "upDown": "↑ then ↓", + "leftThenRight": "← then →", + "rightThenLeft": "→ then ←", + "upLeftThenDown": "↖ then ↓", + "upRightThenDown": "↗ then ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ then ↑", + "upThenDownRight": "↑ then ↘", + "downThenUpRight": "↓ then ↗", + "rightThenUp": "→ then ↑", + "rightThenDown": "→ then ↓", + "downThenRight": "↓ then →", + "downThenLeft": "↓ then ←", + "downRightThenUp": "↘ then ↑", + "downLeftThenUp": "↙ then ↑", + "upLeftThenRight": "↖ then →", + "upRightThenLeft": "↗ then ←" + }, + "toasts": { + "renameNeedsSavedSession": "Rename works on a saved session — send a message first.", + "sessionFolderNeedsWorkspace": "Open a workspace folder first.", + "cycleNeedsMoreAgents": "Only one agent available — nothing to cycle.", + "noOtherRunning": "No other running session to switch to.", + "noOtherRecent": "No other recent session to switch to.", + "noPlanMode": "This agent has no Plan mode.", + "pinToggled": "Pin state updated.", + "noRunningSession": "No running session yet.", + "noModesAvailable": "This agent does not expose any modes.", + "noSwitchTarget": "No mode to switch to (Plan or full-access mode not found)." + }, + "label": "Floating ball", + "panelTitle": "Quick actions", + "settingsTitle": "Floating ball", + "close": "Close", + "resetPosition": "Reset position", + "cheatSheetTitle": "Gestures", + "hint": "Drag the ball to move it. Swipe quickly for a gesture; press and hold to open this panel.", + "favoriteAgentsEmpty": "No available agents", + "recentSessionDepth": "Recent-session switch depth", + "bindingCleared": "Unbound" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3aa2be9e01..8b94ac2a93 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -239,6 +239,88 @@ "appliedToast": "Fondo aplicado", "credit": "Imágenes de wallhaven.cc · solo SFW" } + }, + "actions": { + "newConversation": "Nueva conversación", + "toggleSidebar": "Mostrar u ocultar la barra lateral", + "toggleSearch": "Buscar", + "toggleTerminal": "Mostrar u ocultar la terminal", + "nextTab": "Pestaña siguiente", + "prevTab": "Pestaña anterior", + "reopenClosedTab": "Reabrir pestaña cerrada", + "openFolder": "Abrir carpeta", + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "zoomReset": "Restablecer zoom", + "stopGenerating": "Detener la generación", + "openSettings": "Ajustes", + "openTodos": "Tareas", + "openTodosNew": "Nueva tarea", + "openAutomations": "Automatizaciones", + "openCanvas": "Lienzo", + "openAgents": "Agentes", + "openSystemAdmin": "Administración", + "renameConversation": "Renombrar conversación", + "openSessionFolder": "Archivos de sesión", + "cycleAgent": "Siguiente agente", + "cycleFavoriteAgent": "Agentes favoritos", + "openBallBindings": "Atajos de gestos", + "cycleRunningSessions": "Sesiones en curso", + "cycleRecentSessions": "Sesiones recientes", + "deleteCurrentConversation": "Eliminar sesión actual", + "openImportSessions": "Importar sesiones", + "togglePinConversation": "Fijar / desfijar", + "focusModeSelector": "Selector de modo", + "togglePlanFullAccess": "Plan / acceso total", + "openCollaboration": "Colaboración" + }, + "gestures": { + "tap": "Toque", + "doubleTap": "Doble toque", + "longPress": "Mantener pulsado", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ luego ←", + "upThenRight": "↑ luego →", + "upDown": "↑ luego ↓", + "leftThenRight": "← luego →", + "rightThenLeft": "→ luego ←", + "upLeftThenDown": "↖ luego ↓", + "upRightThenDown": "↗ luego ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ luego ↑", + "upThenDownRight": "↑ luego ↘", + "downThenUpRight": "↓ luego ↗", + "rightThenUp": "→ luego ↑", + "rightThenDown": "→ luego ↓", + "downThenRight": "↓ luego →", + "downThenLeft": "↓ luego ←", + "downRightThenUp": "↘ luego ↑", + "downLeftThenUp": "↙ luego ↑", + "upLeftThenRight": "↖ luego →", + "upRightThenLeft": "↗ luego ←" + }, + "floatingBall": { + "sectionTitle": "Bola flotante", + "sectionDescription": "Una bola arrastrable sobre el espacio de trabajo que convierte los deslizamientos en atajos, pensada para el teléfono, donde no hay teclado.", + "enable": "Mostrar bola flotante", + "hint": "Arrastra para mover, desliza rápido para un gesto, mantén pulsado para el panel y la lista de gestos.", + "resetPosition": "Restablecer posición", + "ballSize": "Tamaño de la bola", + "sizeSmall": "Pequeño", + "sizeMedium": "Mediano", + "sizeLarge": "Grande", + "gestureBindings": "Asignación de gestos", + "bindingCleared": "Sin asignar", + "favoriteAgents": "Agentes favoritos", + "favoriteAgentsHint": "Elige 2-3 agentes para alternar con el gesto de favoritos.", + "favoriteAgentsEmpty": "No hay agentes disponibles", + "panelTitle": "Acciones rápidas" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "El navegador integrado no pudo confirmar que llega al host remoto a través del túnel. Vuelve a intentarlo en un momento." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "Nueva conversación", + "toggleSidebar": "Mostrar u ocultar la barra lateral", + "toggleAuxPanel": "Alternar panel derecho", + "toggleSearch": "Buscar", + "toggleTerminal": "Mostrar u ocultar la terminal", + "nextTab": "Pestaña siguiente", + "prevTab": "Pestaña anterior", + "reopenClosedTab": "Reabrir pestaña cerrada", + "openFolder": "Abrir carpeta", + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "zoomReset": "Restablecer zoom", + "stopGenerating": "Detener la generación", + "openSettings": "Ajustes", + "openTodos": "Tareas", + "openTodosNew": "Nueva tarea", + "openAutomations": "Automatizaciones", + "openCanvas": "Lienzo", + "openAgents": "Agentes", + "openSystemAdmin": "Administración", + "openSessionDetails": "Detalles de sesión", + "openChanges": "Cambios de Git", + "openGitLog": "Commits", + "renameConversation": "Renombrar conversación", + "openSessionFolder": "Archivos de sesión", + "cycleAgent": "Siguiente agente", + "cycleFavoriteAgent": "Agentes favoritos", + "openBallBindings": "Atajos de gestos", + "cycleRunningSessions": "Sesiones en curso", + "cycleRecentSessions": "Sesiones recientes", + "deleteCurrentConversation": "Eliminar sesión actual", + "openImportSessions": "Importar sesiones", + "togglePinConversation": "Fijar / desfijar", + "focusModeSelector": "Selector de modo", + "togglePlanFullAccess": "Plan / acceso total", + "openCollaboration": "Colaboración" + }, + "gestures": { + "tap": "Toque", + "doubleTap": "Doble toque", + "longPress": "Mantener pulsado", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ luego ←", + "upThenRight": "↑ luego →", + "upDown": "↑ luego ↓", + "leftThenRight": "← luego →", + "rightThenLeft": "→ luego ←", + "upLeftThenDown": "↖ luego ↓", + "upRightThenDown": "↗ luego ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ luego ↑", + "upThenDownRight": "↑ luego ↘", + "downThenUpRight": "↓ luego ↗", + "rightThenUp": "→ luego ↑", + "rightThenDown": "→ luego ↓", + "downThenRight": "↓ luego →", + "downThenLeft": "↓ luego ←", + "downRightThenUp": "↘ luego ↑", + "downLeftThenUp": "↙ luego ↑", + "upLeftThenRight": "↖ luego →", + "upRightThenLeft": "↗ luego ←" + }, + "toasts": { + "renameNeedsSavedSession": "Renombrar solo funciona en sesiones guardadas: envía un mensaje primero.", + "sessionFolderNeedsWorkspace": "Abre antes una carpeta de espacio de trabajo.", + "cycleNeedsMoreAgents": "Solo hay un agente disponible: nada que alternar.", + "noOtherRunning": "No hay otra sesión en curso.", + "noOtherRecent": "No hay otra sesión reciente.", + "noPlanMode": "Este agente no tiene modo Plan.", + "pinToggled": "Estado de fijado actualizado.", + "noRunningSession": "Aún no hay sesión en curso.", + "noModesAvailable": "Este agente no expone ningún modo.", + "noSwitchTarget": "No hay modo al que cambiar (no se encontró modo Plan ni de acceso completo)." + }, + "label": "Bola flotante", + "panelTitle": "Acciones rápidas", + "settingsTitle": "Bola flotante", + "close": "Cerrar", + "resetPosition": "Restablecer posición", + "cheatSheetTitle": "Gestos", + "hint": "Arrastra la bola para moverla. Un deslizamiento rápido activa un gesto; una pulsación larga abre este panel.", + "favoriteAgentsEmpty": "No hay agentes disponibles", + "recentSessionDepth": "Profundidad de sesiones recientes", + "bindingCleared": "Sin asignar" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e2647a8ccf..2687755a2a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -239,6 +239,88 @@ "appliedToast": "Fond d'écran appliqué", "credit": "Images de wallhaven.cc · SFW uniquement" } + }, + "actions": { + "newConversation": "Nouvelle conversation", + "toggleSidebar": "Afficher ou masquer la barre latérale", + "toggleSearch": "Rechercher", + "toggleTerminal": "Afficher ou masquer le terminal", + "nextTab": "Onglet suivant", + "prevTab": "Onglet précédent", + "reopenClosedTab": "Rouvrir l’onglet fermé", + "openFolder": "Ouvrir un dossier", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "zoomReset": "Réinitialiser le zoom", + "stopGenerating": "Arrêter la génération", + "openSettings": "Paramètres", + "openTodos": "Tâches", + "openTodosNew": "Nouvelle tâche", + "openAutomations": "Automatisations", + "openCanvas": "Canvas", + "openAgents": "Agents", + "openSystemAdmin": "Administration", + "renameConversation": "Renommer la conversation", + "openSessionFolder": "Fichiers de session", + "cycleAgent": "Agent suivant", + "cycleFavoriteAgent": "Agents favoris", + "openBallBindings": "Assignation des gestes", + "cycleRunningSessions": "Sessions en cours", + "cycleRecentSessions": "Sessions récentes", + "deleteCurrentConversation": "Supprimer la session", + "openImportSessions": "Importer des sessions", + "togglePinConversation": "Épingler / désépingler", + "focusModeSelector": "Sélecteur de mode", + "togglePlanFullAccess": "Plan / accès complet", + "openCollaboration": "Collaboration" + }, + "gestures": { + "tap": "Touche", + "doubleTap": "Double touche", + "longPress": "Appui long", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ puis ←", + "upThenRight": "↑ puis →", + "upDown": "↑ puis ↓", + "leftThenRight": "← puis →", + "rightThenLeft": "→ puis ←", + "upLeftThenDown": "↖ puis ↓", + "upRightThenDown": "↗ puis ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ puis ↑", + "upThenDownRight": "↑ puis ↘", + "downThenUpRight": "↓ puis ↗", + "rightThenUp": "→ puis ↑", + "rightThenDown": "→ puis ↓", + "downThenRight": "↓ puis →", + "downThenLeft": "↓ puis ←", + "downRightThenUp": "↘ puis ↑", + "downLeftThenUp": "↙ puis ↑", + "upLeftThenRight": "↖ puis →", + "upRightThenLeft": "↗ puis ←" + }, + "floatingBall": { + "sectionTitle": "Boule flottante", + "sectionDescription": "Une boule déplaçable au-dessus de l’espace de travail qui transforme les balayages en raccourcis — pensée pour le téléphone, où il n’y a pas de clavier.", + "enable": "Afficher la boule flottante", + "hint": "Faites glisser pour déplacer, balayez rapidement pour un geste, appuyez longuement pour le panneau et la liste des gestes.", + "resetPosition": "Réinitialiser la position", + "ballSize": "Taille de la bille", + "sizeSmall": "Petite", + "sizeMedium": "Moyenne", + "sizeLarge": "Grande", + "gestureBindings": "Affectation des gestes", + "bindingCleared": "Non attribué", + "favoriteAgents": "Agents favoris", + "favoriteAgentsHint": "Choisissez 2-3 agents à parcourir avec le geste des favoris.", + "favoriteAgentsEmpty": "Aucun agent disponible", + "panelTitle": "Actions rapides" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "Le navigateur intégré n'a pas pu confirmer qu'il atteint l'hôte distant par le tunnel. Réessayez dans un instant." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "Nouvelle conversation", + "toggleSidebar": "Afficher ou masquer la barre latérale", + "toggleAuxPanel": "Basculer le panneau droit", + "toggleSearch": "Rechercher", + "toggleTerminal": "Afficher ou masquer le terminal", + "nextTab": "Onglet suivant", + "prevTab": "Onglet précédent", + "reopenClosedTab": "Rouvrir l’onglet fermé", + "openFolder": "Ouvrir un dossier", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "zoomReset": "Réinitialiser le zoom", + "stopGenerating": "Arrêter la génération", + "openSettings": "Paramètres", + "openTodos": "Tâches", + "openTodosNew": "Nouvelle tâche", + "openAutomations": "Automatisations", + "openCanvas": "Canvas", + "openAgents": "Agents", + "openSystemAdmin": "Administration", + "openSessionDetails": "Détails de session", + "openChanges": "Modifications Git", + "openGitLog": "Commits", + "renameConversation": "Renommer la conversation", + "openSessionFolder": "Fichiers de session", + "cycleAgent": "Agent suivant", + "cycleFavoriteAgent": "Agents favoris", + "openBallBindings": "Assignation des gestes", + "cycleRunningSessions": "Sessions en cours", + "cycleRecentSessions": "Sessions récentes", + "deleteCurrentConversation": "Supprimer la session", + "openImportSessions": "Importer des sessions", + "togglePinConversation": "Épingler / désépingler", + "focusModeSelector": "Sélecteur de mode", + "togglePlanFullAccess": "Plan / accès complet", + "openCollaboration": "Collaboration" + }, + "gestures": { + "tap": "Touche", + "doubleTap": "Double touche", + "longPress": "Appui long", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ puis ←", + "upThenRight": "↑ puis →", + "upDown": "↑ puis ↓", + "leftThenRight": "← puis →", + "rightThenLeft": "→ puis ←", + "upLeftThenDown": "↖ puis ↓", + "upRightThenDown": "↗ puis ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ puis ↑", + "upThenDownRight": "↑ puis ↘", + "downThenUpRight": "↓ puis ↗", + "rightThenUp": "→ puis ↑", + "rightThenDown": "→ puis ↓", + "downThenRight": "↓ puis →", + "downThenLeft": "↓ puis ←", + "downRightThenUp": "↘ puis ↑", + "downLeftThenUp": "↙ puis ↑", + "upLeftThenRight": "↖ puis →", + "upRightThenLeft": "↗ puis ←" + }, + "toasts": { + "renameNeedsSavedSession": "Renommer ne s'applique qu'aux sessions enregistrées — envoyez d'abord un message.", + "sessionFolderNeedsWorkspace": "Ouvrez d'abord un dossier d'espace de travail.", + "cycleNeedsMoreAgents": "Un seul agent disponible — rien à parcourir.", + "noOtherRunning": "Aucune autre session en cours.", + "noOtherRecent": "Aucune autre session récente.", + "noPlanMode": "Cet agent n'a pas de mode Plan.", + "pinToggled": "État d'épinglage mis à jour.", + "noRunningSession": "Aucune session en cours.", + "noModesAvailable": "Cet agent n'expose aucun mode.", + "noSwitchTarget": "Aucun mode vers lequel basculer (mode Plan ou accès complet introuvable)." + }, + "label": "Boule flottante", + "panelTitle": "Actions rapides", + "settingsTitle": "Boule flottante", + "close": "Fermer", + "resetPosition": "Réinitialiser la position", + "cheatSheetTitle": "Gestes", + "hint": "Faites glisser la boule pour la déplacer. Un balayage rapide déclenche un geste, un appui long ouvre ce panneau.", + "favoriteAgentsEmpty": "Aucun agent disponible", + "recentSessionDepth": "Profondeur des sessions récentes", + "bindingCleared": "Non attribué" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a45a4fe947..a45f9b6efd 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -239,6 +239,88 @@ "appliedToast": "壁紙を適用しました", "credit": "画像提供 wallhaven.cc · SFW のみ" } + }, + "actions": { + "newConversation": "新しい会話", + "toggleSidebar": "サイドバーの切り替え", + "toggleSearch": "検索", + "toggleTerminal": "ターミナルの切り替え", + "nextTab": "次のタブ", + "prevTab": "前のタブ", + "reopenClosedTab": "閉じたタブを開き直す", + "openFolder": "フォルダーを開く", + "zoomIn": "拡大", + "zoomOut": "縮小", + "zoomReset": "ズームをリセット", + "stopGenerating": "生成を停止", + "openSettings": "設定", + "openTodos": "ToDo", + "openTodosNew": "ToDo作成", + "openAutomations": "オートメーション", + "openCanvas": "キャンバス", + "openAgents": "エージェント", + "openSystemAdmin": "システム管理", + "renameConversation": "会話名を変更", + "openSessionFolder": "セッションファイル", + "cycleAgent": "次のエージェント", + "cycleFavoriteAgent": "お気に入りエージェント", + "openBallBindings": "ジェスチャー設定", + "cycleRunningSessions": "実行中のセッション", + "cycleRecentSessions": "最近のセッション", + "deleteCurrentConversation": "現在のセッションを削除", + "openImportSessions": "セッションをインポート", + "togglePinConversation": "ピン留め切り替え", + "focusModeSelector": "モード選択", + "togglePlanFullAccess": "Plan/フルアクセス切替", + "openCollaboration": "コラボレーション" + }, + "gestures": { + "tap": "タップ", + "doubleTap": "ダブルタップ", + "longPress": "長押し", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ その後 ←", + "upThenRight": "↑ その後 →", + "upDown": "↑ その後 ↓", + "leftThenRight": "← その後 →", + "rightThenLeft": "→ その後 ←", + "upLeftThenDown": "↖ その後 ↓", + "upRightThenDown": "↗ その後 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ その後 ↑", + "upThenDownRight": "↑ その後 ↘", + "downThenUpRight": "↓ その後 ↗", + "rightThenUp": "→ その後 ↑", + "rightThenDown": "→ その後 ↓", + "downThenRight": "↓ その後 →", + "downThenLeft": "↓ その後 ←", + "downRightThenUp": "↘ その後 ↑", + "downLeftThenUp": "↙ その後 ↑", + "upLeftThenRight": "↖ その後 →", + "upRightThenLeft": "↗ その後 ←" + }, + "floatingBall": { + "sectionTitle": "フローティングボール", + "sectionDescription": "ワークスペース上に置くドラッグ可能なボール。スワイプをショートカットに変えます。キーボードのないスマートフォン向けです。", + "enable": "フローティングボールを表示", + "hint": "ドラッグで移動、素早いスワイプでジェスチャー、長押しでパネルとジェスチャー一覧を開きます。", + "resetPosition": "位置をリセット", + "ballSize": "ボールサイズ", + "sizeSmall": "小", + "sizeMedium": "中", + "sizeLarge": "大", + "gestureBindings": "ジェスチャー割り当て", + "bindingCleared": "未割り当て", + "favoriteAgents": "お気に入りエージェント", + "favoriteAgentsHint": "2〜3個のエージェントを選ぶと、お気に入りジェスチャーで切り替えられます。", + "favoriteAgentsEmpty": "利用可能なエージェントがありません", + "panelTitle": "クイック操作" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "内蔵ブラウザーがトンネル経由でリモートホストに届くことを確認できませんでした。しばらくしてからもう一度お試しください。" } } + }, + "FloatingBall": { + "actions": { + "newConversation": "新しい会話", + "toggleSidebar": "サイドバーの切り替え", + "toggleAuxPanel": "右パネルの切り替え", + "toggleSearch": "検索", + "toggleTerminal": "ターミナルの切り替え", + "nextTab": "次のタブ", + "prevTab": "前のタブ", + "reopenClosedTab": "閉じたタブを開き直す", + "openFolder": "フォルダーを開く", + "zoomIn": "拡大", + "zoomOut": "縮小", + "zoomReset": "ズームをリセット", + "stopGenerating": "生成を停止", + "openSettings": "設定", + "openTodos": "ToDo", + "openTodosNew": "ToDo作成", + "openAutomations": "オートメーション", + "openCanvas": "キャンバス", + "openAgents": "エージェント", + "openSystemAdmin": "システム管理", + "openSessionDetails": "セッション詳細", + "openChanges": "Git 変更", + "openGitLog": "コミット", + "renameConversation": "会話名を変更", + "openSessionFolder": "セッションファイル", + "cycleAgent": "次のエージェント", + "cycleFavoriteAgent": "お気に入りエージェント", + "openBallBindings": "ジェスチャー設定", + "cycleRunningSessions": "実行中のセッション", + "cycleRecentSessions": "最近のセッション", + "deleteCurrentConversation": "現在のセッションを削除", + "openImportSessions": "セッションをインポート", + "togglePinConversation": "ピン留め切り替え", + "focusModeSelector": "モード選択", + "togglePlanFullAccess": "Plan/フルアクセス切替", + "openCollaboration": "コラボレーション" + }, + "gestures": { + "tap": "タップ", + "doubleTap": "ダブルタップ", + "longPress": "長押し", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ その後 ←", + "upThenRight": "↑ その後 →", + "upDown": "↑ その後 ↓", + "leftThenRight": "← その後 →", + "rightThenLeft": "→ その後 ←", + "upLeftThenDown": "↖ その後 ↓", + "upRightThenDown": "↗ その後 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ その後 ↑", + "upThenDownRight": "↑ その後 ↘", + "downThenUpRight": "↓ その後 ↗", + "rightThenUp": "→ その後 ↑", + "rightThenDown": "→ その後 ↓", + "downThenRight": "↓ その後 →", + "downThenLeft": "↓ その後 ←", + "downRightThenUp": "↘ その後 ↑", + "downLeftThenUp": "↙ その後 ↑", + "upLeftThenRight": "↖ その後 →", + "upRightThenLeft": "↗ その後 ←" + }, + "toasts": { + "renameNeedsSavedSession": "名前の変更は保存済みセッションのみ有効です。まずメッセージを送信してください。", + "sessionFolderNeedsWorkspace": "先にワークスペースフォルダを開いてください。", + "cycleNeedsMoreAgents": "利用可能なエージェントが1つだけのため、切り替えできません。", + "noOtherRunning": "切り替え先の実行中セッションがありません", + "noOtherRecent": "切り替え先の最近のセッションがありません", + "noPlanMode": "このエージェントにPlanモードはありません", + "pinToggled": "ピン留め状態を更新しました", + "noRunningSession": "実行中のセッションはまだありません", + "noModesAvailable": "このエージェントには利用可能なモードがありません。", + "noSwitchTarget": "切り替え先のモードがありません(Plan または完全アクセスモードが見つかりません)。" + }, + "label": "フローティングボール", + "panelTitle": "クイック操作", + "settingsTitle": "フローティングボール", + "close": "閉じる", + "resetPosition": "位置をリセット", + "cheatSheetTitle": "ジェスチャー", + "hint": "ボールをドラッグして移動します。素早くスワイプするとジェスチャー、長押しでこのパネルが開きます。", + "favoriteAgentsEmpty": "利用可能なエージェントがありません", + "recentSessionDepth": "最近セッション切替数", + "bindingCleared": "未割り当て" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 84ed15428e..b9dc68dd92 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -239,6 +239,88 @@ "appliedToast": "배경화면이 적용되었습니다", "credit": "이미지 제공 wallhaven.cc · SFW만 표시" } + }, + "actions": { + "newConversation": "새 대화", + "toggleSidebar": "사이드바 전환", + "toggleSearch": "검색", + "toggleTerminal": "터미널 전환", + "nextTab": "다음 탭", + "prevTab": "이전 탭", + "reopenClosedTab": "닫은 탭 다시 열기", + "openFolder": "폴더 열기", + "zoomIn": "확대", + "zoomOut": "축소", + "zoomReset": "확대/축소 초기화", + "stopGenerating": "생성 중지", + "openSettings": "설정", + "openTodos": "할 일", + "openTodosNew": "새 할 일", + "openAutomations": "자동화", + "openCanvas": "캔버스", + "openAgents": "에이전트", + "openSystemAdmin": "시스템 관리", + "renameConversation": "대화 이름 바꾸기", + "openSessionFolder": "세션 파일", + "cycleAgent": "다음 에이전트", + "cycleFavoriteAgent": "즐겨쓰는 에이전트", + "openBallBindings": "제스처 바인딩", + "cycleRunningSessions": "실행 중인 세션", + "cycleRecentSessions": "최근 세션", + "deleteCurrentConversation": "현재 세션 삭제", + "openImportSessions": "セッション 가져오기", + "togglePinConversation": "고정/고정 해제", + "focusModeSelector": "모드 선택", + "togglePlanFullAccess": "Plan/전체 권한 전환", + "openCollaboration": "협업" + }, + "gestures": { + "tap": "탭", + "doubleTap": "더블 탭", + "longPress": "길게 누름", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ 후 ←", + "upThenRight": "↑ 후 →", + "upDown": "↑ 후 ↓", + "leftThenRight": "← 후 →", + "rightThenLeft": "→ 후 ←", + "upLeftThenDown": "↖ 후 ↓", + "upRightThenDown": "↗ 후 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ 후 ↑", + "upThenDownRight": "↑ 후 ↘", + "downThenUpRight": "↓ 후 ↗", + "rightThenUp": "→ 후 ↑", + "rightThenDown": "→ 후 ↓", + "downThenRight": "↓ 후 →", + "downThenLeft": "↓ 후 ←", + "downRightThenUp": "↘ 후 ↑", + "downLeftThenUp": "↙ 후 ↑", + "upLeftThenRight": "↖ 후 →", + "upRightThenLeft": "↗ 후 ←" + }, + "floatingBall": { + "sectionTitle": "플로팅 볼", + "sectionDescription": "작업 공간 위의 드래그 가능한 볼로, 스와이프를 단축키로 바꿉니다. 키보드가 없는 휴대폰을 위한 기능입니다.", + "enable": "플로팅 볼 표시", + "hint": "드래그로 이동, 빠른 스와이프로 제스처, 길게 눌러 패널과 제스처 목록을 엽니다.", + "resetPosition": "위치 초기화", + "ballSize": "공 크기", + "sizeSmall": "작게", + "sizeMedium": "보통", + "sizeLarge": "크게", + "gestureBindings": "제스처 바인딩", + "bindingCleared": "미지정", + "favoriteAgents": "즐겨쓰는 에이전트", + "favoriteAgentsHint": "2~3개의 에이전트를 선택하면 즐겨쓰기 제스처로 순환 전환됩니다.", + "favoriteAgentsEmpty": "사용 가능한 에이전트가 없습니다", + "panelTitle": "빠른 작업" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "내장 브라우저가 터널을 통해 원격 호스트에 도달하는지 확인할 수 없습니다. 잠시 후 다시 시도하세요." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "새 대화", + "toggleSidebar": "사이드바 전환", + "toggleAuxPanel": "오른쪽 패널 전환", + "toggleSearch": "검색", + "toggleTerminal": "터미널 전환", + "nextTab": "다음 탭", + "prevTab": "이전 탭", + "reopenClosedTab": "닫은 탭 다시 열기", + "openFolder": "폴더 열기", + "zoomIn": "확대", + "zoomOut": "축소", + "zoomReset": "확대/축소 초기화", + "stopGenerating": "생성 중지", + "openSettings": "설정", + "openTodos": "할 일", + "openTodosNew": "새 할 일", + "openAutomations": "자동화", + "openCanvas": "캔버스", + "openAgents": "에이전트", + "openSystemAdmin": "시스템 관리", + "openSessionDetails": "세션 세부정보", + "openChanges": "Git 변경 사항", + "openGitLog": "커밋", + "renameConversation": "대화 이름 바꾸기", + "openSessionFolder": "세션 파일", + "cycleAgent": "다음 에이전트", + "cycleFavoriteAgent": "즐겨쓰는 에이전트", + "openBallBindings": "제스처 바인딩", + "cycleRunningSessions": "실행 중인 세션", + "cycleRecentSessions": "최근 세션", + "deleteCurrentConversation": "현재 세션 삭제", + "openImportSessions": "セッション 가져오기", + "togglePinConversation": "고정/고정 해제", + "focusModeSelector": "모드 선택", + "togglePlanFullAccess": "Plan/전체 권한 전환", + "openCollaboration": "협업" + }, + "gestures": { + "tap": "탭", + "doubleTap": "더블 탭", + "longPress": "길게 누름", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ 후 ←", + "upThenRight": "↑ 후 →", + "upDown": "↑ 후 ↓", + "leftThenRight": "← 후 →", + "rightThenLeft": "→ 후 ←", + "upLeftThenDown": "↖ 후 ↓", + "upRightThenDown": "↗ 후 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ 후 ↑", + "upThenDownRight": "↑ 후 ↘", + "downThenUpRight": "↓ 후 ↗", + "rightThenUp": "→ 후 ↑", + "rightThenDown": "→ 후 ↓", + "downThenRight": "↓ 후 →", + "downThenLeft": "↓ 후 ←", + "downRightThenUp": "↘ 후 ↑", + "downLeftThenUp": "↙ 후 ↑", + "upLeftThenRight": "↖ 후 →", + "upRightThenLeft": "↗ 후 ←" + }, + "toasts": { + "renameNeedsSavedSession": "이름 바꾸기는 저장된 세션에만 적용됩니다. 먼저 메시지를 보내세요.", + "sessionFolderNeedsWorkspace": "먼저 작업 공간 폴더를 여세요.", + "cycleNeedsMoreAgents": "사용 가능한 에이전트가 하나뿐이어서 전환할 수 없습니다.", + "noOtherRunning": "전환할 다른 실행 세션이 없습니다", + "noOtherRecent": "전환할 다른 최근 세션이 없습니다", + "noPlanMode": "이 에이전트에 Plan 모드가 없습니다", + "pinToggled": "고정 상태가 변경되었습니다", + "noRunningSession": "아직 실행 중인 세션이 없습니다", + "noModesAvailable": "이 에이전트에는 사용 가능한 모드가 없습니다.", + "noSwitchTarget": "전환할 모드가 없습니다(Plan 또는 전체 접근 모드를 찾을 수 없음)." + }, + "label": "플로팅 볼", + "panelTitle": "빠른 작업", + "settingsTitle": "플로팅 볼", + "close": "닫기", + "resetPosition": "위치 초기화", + "cheatSheetTitle": "제스처", + "hint": "볼을 드래그해 이동하세요. 빠르게 스와이프하면 제스처, 길게 누르면 이 패널이 열립니다.", + "favoriteAgentsEmpty": "사용 가능한 에이전트가 없습니다", + "recentSessionDepth": "최근 세션 전환 깊이", + "bindingCleared": "미지정" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 9481e44ee7..741efc0626 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -239,6 +239,88 @@ "appliedToast": "Papel de parede aplicado", "credit": "Imagens de wallhaven.cc · apenas SFW" } + }, + "actions": { + "newConversation": "Nova conversa", + "toggleSidebar": "Alternar barra lateral", + "toggleSearch": "Pesquisar", + "toggleTerminal": "Alternar terminal", + "nextTab": "Próxima aba", + "prevTab": "Aba anterior", + "reopenClosedTab": "Reabrir aba fechada", + "openFolder": "Abrir pasta", + "zoomIn": "Aumentar zoom", + "zoomOut": "Diminuir zoom", + "zoomReset": "Redefinir zoom", + "stopGenerating": "Parar geração", + "openSettings": "Configurações", + "openTodos": "Tarefas", + "openTodosNew": "Nova tarefa", + "openAutomations": "Automações", + "openCanvas": "Canvas", + "openAgents": "Agentes", + "openSystemAdmin": "Administração", + "renameConversation": "Renomear conversa", + "openSessionFolder": "Arquivos da sessão", + "cycleAgent": "Próximo agente", + "cycleFavoriteAgent": "Agentes favoritos", + "openBallBindings": "Atalhos de gestos", + "cycleRunningSessions": "Sessões em execução", + "cycleRecentSessions": "Sessões recentes", + "deleteCurrentConversation": "Excluir sessão atual", + "openImportSessions": "Importar sessões", + "togglePinConversation": "Fixar / desafixar", + "focusModeSelector": "Seletor de modo", + "togglePlanFullAccess": "Plano / acesso total", + "openCollaboration": "Colaboração" + }, + "gestures": { + "tap": "Toque", + "doubleTap": "Toque duplo", + "longPress": "Pressão longa", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ depois ←", + "upThenRight": "↑ depois →", + "upDown": "↑ depois ↓", + "leftThenRight": "← depois →", + "rightThenLeft": "→ depois ←", + "upLeftThenDown": "↖ depois ↓", + "upRightThenDown": "↗ depois ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ depois ↑", + "upThenDownRight": "↑ depois ↘", + "downThenUpRight": "↓ depois ↗", + "rightThenUp": "→ depois ↑", + "rightThenDown": "→ depois ↓", + "downThenRight": "↓ depois →", + "downThenLeft": "↓ depois ←", + "downRightThenUp": "↘ depois ↑", + "downLeftThenUp": "↙ depois ↑", + "upLeftThenRight": "↖ depois →", + "upRightThenLeft": "↗ depois ←" + }, + "floatingBall": { + "sectionTitle": "Bola flutuante", + "sectionDescription": "Uma bola arrastável sobre o espaço de trabalho que transforma deslizes em atalhos — feita para o telefone, onde não há teclado.", + "enable": "Mostrar bola flutuante", + "hint": "Arraste para mover, deslize rápido para um gesto, toque e segure para o painel e a lista de gestos.", + "resetPosition": "Redefinir posição", + "ballSize": "Tamanho da bola", + "sizeSmall": "Pequeno", + "sizeMedium": "Médio", + "sizeLarge": "Grande", + "gestureBindings": "Atribuição de gestos", + "bindingCleared": "Sem atribuição", + "favoriteAgents": "Agentes favoritos", + "favoriteAgentsHint": "Escolha 2-3 agentes para alternar com o gesto de favoritos.", + "favoriteAgentsEmpty": "Nenhum agente disponível", + "panelTitle": "Ações rápidas" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "O navegador integrado não conseguiu confirmar que alcança o host remoto pelo túnel. Tente novamente em instantes." } } + }, + "FloatingBall": { + "actions": { + "newConversation": "Nova conversa", + "toggleSidebar": "Alternar barra lateral", + "toggleAuxPanel": "Alternar painel direito", + "toggleSearch": "Pesquisar", + "toggleTerminal": "Alternar terminal", + "nextTab": "Próxima aba", + "prevTab": "Aba anterior", + "reopenClosedTab": "Reabrir aba fechada", + "openFolder": "Abrir pasta", + "zoomIn": "Aumentar zoom", + "zoomOut": "Diminuir zoom", + "zoomReset": "Redefinir zoom", + "stopGenerating": "Parar geração", + "openSettings": "Configurações", + "openTodos": "Tarefas", + "openTodosNew": "Nova tarefa", + "openAutomations": "Automações", + "openCanvas": "Canvas", + "openAgents": "Agentes", + "openSystemAdmin": "Administração", + "openSessionDetails": "Detalhes da sessão", + "openChanges": "Alterações do Git", + "openGitLog": "Commits", + "renameConversation": "Renomear conversa", + "openSessionFolder": "Arquivos da sessão", + "cycleAgent": "Próximo agente", + "cycleFavoriteAgent": "Agentes favoritos", + "openBallBindings": "Atalhos de gestos", + "cycleRunningSessions": "Sessões em execução", + "cycleRecentSessions": "Sessões recentes", + "deleteCurrentConversation": "Excluir sessão atual", + "openImportSessions": "Importar sessões", + "togglePinConversation": "Fixar / desafixar", + "focusModeSelector": "Seletor de modo", + "togglePlanFullAccess": "Plano / acesso total", + "openCollaboration": "Colaboração" + }, + "gestures": { + "tap": "Toque", + "doubleTap": "Toque duplo", + "longPress": "Pressão longa", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ depois ←", + "upThenRight": "↑ depois →", + "upDown": "↑ depois ↓", + "leftThenRight": "← depois →", + "rightThenLeft": "→ depois ←", + "upLeftThenDown": "↖ depois ↓", + "upRightThenDown": "↗ depois ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ depois ↑", + "upThenDownRight": "↑ depois ↘", + "downThenUpRight": "↓ depois ↗", + "rightThenUp": "→ depois ↑", + "rightThenDown": "→ depois ↓", + "downThenRight": "↓ depois →", + "downThenLeft": "↓ depois ←", + "downRightThenUp": "↘ depois ↑", + "downLeftThenUp": "↙ depois ↑", + "upLeftThenRight": "↖ depois →", + "upRightThenLeft": "↗ depois ←" + }, + "toasts": { + "renameNeedsSavedSession": "Renomear só funciona em sessões salvas — envie uma mensagem primeiro.", + "sessionFolderNeedsWorkspace": "Abra antes uma pasta de espaço de trabalho.", + "cycleNeedsMoreAgents": "Só há um agente disponível — nada para alternar.", + "noOtherRunning": "Nenhuma outra sessão em execução.", + "noOtherRecent": "Nenhuma outra sessão recente.", + "noPlanMode": "Este agente não tem modo Plano.", + "pinToggled": "Estado de fixação atualizado.", + "noRunningSession": "Ainda não há sessão em execução.", + "noModesAvailable": "Este agente não expõe nenhum modo.", + "noSwitchTarget": "Nenhum modo para alternar (modo Plan ou de acesso completo não encontrado)." + }, + "label": "Bola flutuante", + "panelTitle": "Ações rápidas", + "settingsTitle": "Bola flutuante", + "close": "Fechar", + "resetPosition": "Redefinir posição", + "cheatSheetTitle": "Gestos", + "hint": "Arraste a bola para movê-la. Um deslize rápido dispara um gesto; um toque longo abre este painel.", + "favoriteAgentsEmpty": "Nenhum agente disponível", + "recentSessionDepth": "Profundidade de sessões recentes", + "bindingCleared": "Sem atribuição" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b4b1a362b0..66e2dd7982 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -239,6 +239,88 @@ "appliedToast": "壁纸已应用", "credit": "图片来源 wallhaven.cc · 仅 SFW 内容" } + }, + "actions": { + "newConversation": "新建会话", + "toggleSidebar": "切换侧栏", + "toggleSearch": "搜索", + "toggleTerminal": "切换终端", + "nextTab": "下一个标签", + "prevTab": "上一个标签", + "reopenClosedTab": "恢复关闭的标签", + "openFolder": "打开文件夹", + "zoomIn": "放大", + "zoomOut": "缩小", + "zoomReset": "重置缩放", + "stopGenerating": "停止生成", + "openSettings": "设置", + "openTodos": "待办任务", + "openTodosNew": "新建待办", + "openAutomations": "自动化", + "openCanvas": "无限画布", + "openAgents": "智能体", + "openSystemAdmin": "系统管理", + "renameConversation": "重命名会话", + "openSessionFolder": "会话文件", + "cycleAgent": "下一个智能体", + "cycleFavoriteAgent": "常用智能体", + "openBallBindings": "手势绑定", + "cycleRunningSessions": "运行中的会话", + "cycleRecentSessions": "最近的会话", + "deleteCurrentConversation": "删除当前会话", + "openImportSessions": "导入会话", + "togglePinConversation": "置顶/取消置顶", + "focusModeSelector": "模式选择", + "togglePlanFullAccess": "Plan/全权限切换", + "openCollaboration": "协作" + }, + "gestures": { + "tap": "单击", + "doubleTap": "双击", + "longPress": "长按", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ 然后 ←", + "upThenRight": "↑ 然后 →", + "upDown": "↑ 然后 ↓", + "leftThenRight": "← 然后 →", + "rightThenLeft": "→ 然后 ←", + "upLeftThenDown": "↖ 然后 ↓", + "upRightThenDown": "↗ 然后 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ 然后 ↑", + "upThenDownRight": "↑ 然后 ↘", + "downThenUpRight": "↓ 然后 ↗", + "rightThenUp": "→ 然后 ↑", + "rightThenDown": "→ 然后 ↓", + "downThenRight": "↓ 然后 →", + "downThenLeft": "↓ 然后 ←", + "downRightThenUp": "↘ 然后 ↑", + "downLeftThenUp": "↙ 然后 ↑", + "upLeftThenRight": "↖ 然后 →", + "upRightThenLeft": "↗ 然后 ←" + }, + "floatingBall": { + "sectionTitle": "悬浮球", + "sectionDescription": "工作区上的可拖动悬浮球,把滑动手势变成快捷键——为没有键盘的手机而设。", + "enable": "显示悬浮球", + "hint": "拖动可移动;快速滑动触发手势;长按打开面板与手势表。", + "resetPosition": "重置位置", + "ballSize": "球体大小", + "sizeSmall": "小", + "sizeMedium": "中", + "sizeLarge": "大", + "gestureBindings": "手势绑定", + "bindingCleared": "未绑定", + "favoriteAgents": "常用智能体", + "favoriteAgentsHint": "选择 2-3 个智能体,供「常用智能体」手势循环快切。", + "favoriteAgentsEmpty": "暂无可用智能体", + "panelTitle": "快捷操作" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "内置浏览器无法确认它能通过隧道访问远端主机。请稍后重试。" } } + }, + "FloatingBall": { + "actions": { + "newConversation": "新建会话", + "toggleSidebar": "切换侧栏", + "toggleAuxPanel": "开关右侧面板", + "toggleSearch": "搜索", + "toggleTerminal": "切换终端", + "nextTab": "下一个标签", + "prevTab": "上一个标签", + "reopenClosedTab": "恢复关闭的标签", + "openFolder": "打开文件夹", + "zoomIn": "放大", + "zoomOut": "缩小", + "zoomReset": "重置缩放", + "stopGenerating": "停止生成", + "openSettings": "设置", + "openTodos": "待办任务", + "openTodosNew": "新建待办", + "openAutomations": "自动化", + "openCanvas": "无限画布", + "openAgents": "智能体", + "openSystemAdmin": "系统管理", + "openSessionDetails": "会话详情", + "openChanges": "Git 改动", + "openGitLog": "提交记录", + "renameConversation": "重命名会话", + "openSessionFolder": "会话文件", + "cycleAgent": "下一个智能体", + "cycleFavoriteAgent": "常用智能体", + "openBallBindings": "手势绑定", + "cycleRunningSessions": "运行中的会话", + "cycleRecentSessions": "最近的会话", + "deleteCurrentConversation": "删除当前会话", + "openImportSessions": "导入会话", + "togglePinConversation": "置顶/取消置顶", + "focusModeSelector": "模式选择", + "togglePlanFullAccess": "Plan/全权限切换", + "openCollaboration": "协作" + }, + "gestures": { + "tap": "单击", + "doubleTap": "双击", + "longPress": "长按", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ 然后 ←", + "upThenRight": "↑ 然后 →", + "upDown": "↑ 然后 ↓", + "leftThenRight": "← 然后 →", + "rightThenLeft": "→ 然后 ←", + "upLeftThenDown": "↖ 然后 ↓", + "upRightThenDown": "↗ 然后 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ 然后 ↑", + "upThenDownRight": "↑ 然后 ↘", + "downThenUpRight": "↓ 然后 ↗", + "rightThenUp": "→ 然后 ↑", + "rightThenDown": "→ 然后 ↓", + "downThenRight": "↓ 然后 →", + "downThenLeft": "↓ 然后 ←", + "downRightThenUp": "↘ 然后 ↑", + "downLeftThenUp": "↙ 然后 ↑", + "upLeftThenRight": "↖ 然后 →", + "upRightThenLeft": "↗ 然后 ←" + }, + "toasts": { + "renameNeedsSavedSession": "重命名只对已保存的会话生效——先发一条消息。", + "sessionFolderNeedsWorkspace": "请先打开一个工作区文件夹。", + "cycleNeedsMoreAgents": "可用智能体只有一个,无可切换。", + "noOtherRunning": "没有其他运行中的会话", + "noOtherRecent": "没有其他最近的会话", + "noPlanMode": "当前智能体没有 Plan 模式", + "pinToggled": "置顶状态已更新", + "noRunningSession": "还没有运行中的会话", + "noModesAvailable": "该智能体没有可用模式。", + "noSwitchTarget": "没有可切换的模式(未找到 Plan 或完整访问模式)。" + }, + "label": "悬浮球", + "panelTitle": "快捷操作", + "settingsTitle": "悬浮球", + "close": "关闭", + "resetPosition": "重置位置", + "cheatSheetTitle": "手势", + "hint": "拖动悬浮球可移动位置。快速滑动触发手势,长按打开此面板。", + "favoriteAgentsEmpty": "暂无可用智能体", + "recentSessionDepth": "最近会话切换深度", + "bindingCleared": "未绑定" } -} +} \ No newline at end of file diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 86567c400d..cee2581641 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -239,6 +239,88 @@ "appliedToast": "桌布已套用", "credit": "圖片來源 wallhaven.cc · 僅 SFW 內容" } + }, + "actions": { + "newConversation": "新增對話", + "toggleSidebar": "切換側欄", + "toggleSearch": "搜尋", + "toggleTerminal": "切換終端機", + "nextTab": "下一個分頁", + "prevTab": "上一個分頁", + "reopenClosedTab": "恢復關閉的分頁", + "openFolder": "開啟資料夾", + "zoomIn": "放大", + "zoomOut": "縮小", + "zoomReset": "重設縮放", + "stopGenerating": "停止生成", + "openSettings": "設定", + "openTodos": "待辦任務", + "openTodosNew": "新建待辦", + "openAutomations": "自動化", + "openCanvas": "無限畫布", + "openAgents": "智慧代理人", + "openSystemAdmin": "系統管理", + "renameConversation": "重新命名會話", + "openSessionFolder": "會話檔案", + "cycleAgent": "下一個代理人", + "cycleFavoriteAgent": "常用代理人", + "openBallBindings": "手勢綁定", + "cycleRunningSessions": "執行中的會話", + "cycleRecentSessions": "最近的會話", + "deleteCurrentConversation": "刪除當前會話", + "openImportSessions": "匯入會話", + "togglePinConversation": "置頂/取消置頂", + "focusModeSelector": "模式選擇", + "togglePlanFullAccess": "Plan/全權限切換", + "openCollaboration": "協作" + }, + "gestures": { + "tap": "單擊", + "doubleTap": "雙擊", + "longPress": "長按", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ 然後 ←", + "upThenRight": "↑ 然後 →", + "upDown": "↑ 然後 ↓", + "leftThenRight": "← 然後 →", + "rightThenLeft": "→ 然後 ←", + "upLeftThenDown": "↖ 然後 ↓", + "upRightThenDown": "↗ 然後 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ 然後 ↑", + "upThenDownRight": "↑ 然後 ↘", + "downThenUpRight": "↓ 然後 ↗", + "rightThenUp": "→ 然後 ↑", + "rightThenDown": "→ 然後 ↓", + "downThenRight": "↓ 然後 →", + "downThenLeft": "↓ 然後 ←", + "downRightThenUp": "↘ 然後 ↑", + "downLeftThenUp": "↙ 然後 ↑", + "upLeftThenRight": "↖ 然後 →", + "upRightThenLeft": "↗ 然後 ←" + }, + "floatingBall": { + "sectionTitle": "懸浮球", + "sectionDescription": "工作區上的可拖動懸浮球,把滑動手勢變成快捷鍵——為沒有鍵盤的手機而設。", + "enable": "顯示懸浮球", + "hint": "拖動可移動;快速滑動觸發手勢;長按打開面板與手勢表。", + "resetPosition": "重設位置", + "ballSize": "球體大小", + "sizeSmall": "小", + "sizeMedium": "中", + "sizeLarge": "大", + "gestureBindings": "手勢綁定", + "bindingCleared": "未綁定", + "favoriteAgents": "常用代理人", + "favoriteAgentsHint": "選擇 2-3 個代理人,供「常用代理人」手勢循環快切。", + "favoriteAgentsEmpty": "暫無可用代理人", + "panelTitle": "快捷操作" } }, "SystemSettings": { @@ -7349,5 +7431,98 @@ "probeFailed": "內建瀏覽器無法確認它能透過通道連到遠端主機。請稍後再試。" } } + }, + "FloatingBall": { + "actions": { + "newConversation": "新增對話", + "toggleSidebar": "切換側欄", + "toggleAuxPanel": "開關右側面板", + "toggleSearch": "搜尋", + "toggleTerminal": "切換終端機", + "nextTab": "下一個分頁", + "prevTab": "上一個分頁", + "reopenClosedTab": "恢復關閉的分頁", + "openFolder": "開啟資料夾", + "zoomIn": "放大", + "zoomOut": "縮小", + "zoomReset": "重設縮放", + "stopGenerating": "停止生成", + "openSettings": "設定", + "openTodos": "待辦任務", + "openTodosNew": "新建待辦", + "openAutomations": "自動化", + "openCanvas": "無限畫布", + "openAgents": "智慧代理人", + "openSystemAdmin": "系統管理", + "openSessionDetails": "會話詳情", + "openChanges": "Git 變更", + "openGitLog": "提交記錄", + "renameConversation": "重新命名會話", + "openSessionFolder": "會話檔案", + "cycleAgent": "下一個代理人", + "cycleFavoriteAgent": "常用代理人", + "openBallBindings": "手勢綁定", + "cycleRunningSessions": "執行中的會話", + "cycleRecentSessions": "最近的會話", + "deleteCurrentConversation": "刪除當前會話", + "openImportSessions": "匯入會話", + "togglePinConversation": "置頂/取消置頂", + "focusModeSelector": "模式選擇", + "togglePlanFullAccess": "Plan/全權限切換", + "openCollaboration": "協作" + }, + "gestures": { + "tap": "單擊", + "doubleTap": "雙擊", + "longPress": "長按", + "left": "←", + "right": "→", + "up": "↑", + "upLeft": "↖", + "upRight": "↗", + "upThenLeft": "↑ 然後 ←", + "upThenRight": "↑ 然後 →", + "upDown": "↑ 然後 ↓", + "leftThenRight": "← 然後 →", + "rightThenLeft": "→ 然後 ←", + "upLeftThenDown": "↖ 然後 ↓", + "upRightThenDown": "↗ 然後 ↓", + "down": "↓", + "downLeft": "↙", + "downRight": "↘", + "downUp": "↓ 然後 ↑", + "upThenDownRight": "↑ 然後 ↘", + "downThenUpRight": "↓ 然後 ↗", + "rightThenUp": "→ 然後 ↑", + "rightThenDown": "→ 然後 ↓", + "downThenRight": "↓ 然後 →", + "downThenLeft": "↓ 然後 ←", + "downRightThenUp": "↘ 然後 ↑", + "downLeftThenUp": "↙ 然後 ↑", + "upLeftThenRight": "↖ 然後 →", + "upRightThenLeft": "↗ 然後 ←" + }, + "toasts": { + "renameNeedsSavedSession": "重新命名只對已儲存的會話生效——先發一則訊息。", + "sessionFolderNeedsWorkspace": "請先開啟一個工作區資料夾。", + "cycleNeedsMoreAgents": "可用智慧型代理只有一個,無可切換。", + "noOtherRunning": "沒有其他執行中的會話", + "noOtherRecent": "沒有其他最近的會話", + "noPlanMode": "當前智能體沒有 Plan 模式", + "pinToggled": "置頂狀態已更新", + "noRunningSession": "還沒有執行中的會話", + "noModesAvailable": "該智慧代理沒有可用模式。", + "noSwitchTarget": "沒有可切換的模式(未找到 Plan 或完整存取模式)。" + }, + "label": "懸浮球", + "panelTitle": "快捷操作", + "settingsTitle": "懸浮球", + "close": "關閉", + "resetPosition": "重設位置", + "cheatSheetTitle": "手勢", + "hint": "拖動懸浮球可移動位置。快速滑動觸發手勢,長按打開此面板。", + "favoriteAgentsEmpty": "暫無可用代理人", + "recentSessionDepth": "最近會話切換深度", + "bindingCleared": "未綁定" } -} +} \ No newline at end of file diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index 996bd35424..4d9f2475d8 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -29,6 +29,18 @@ export const STORAGE_KEY_CHAT_ANIMATIONS = "codeg-chat-animations" // (48rem)。需预水合,避免会话首屏先按默认宽度排版再跳变。 export const STORAGE_KEY_CHAT_CONTENT_WIDTH = "codeg-chat-content-width" +// 悬浮球(手势入口)开关与位置。默认关闭:它是常驻在会话之上的控件,没有显式 +// 开启就不该出现。位置存相对视口左上角的 px,缺省表示停在右下角。两者都无需 +// 预水合——水合前根本不渲染球。 +export const STORAGE_KEY_FLOATING_BALL_ENABLED = "codeg-floating-ball-enabled" +export const STORAGE_KEY_FLOATING_BALL_POSITION = "codeg-floating-ball-position" +// 批v82:手势→动作绑定表(JSON,gesture → action | null;null 表示用户显式清除, +// 与"缺 key 回退默认"语义不同)、球体三档大小、常用 agent 快切列表。同样无需预水合。 +export const STORAGE_KEY_FLOATING_BALL_BINDINGS = "codeg-floating-ball-bindings" +export const STORAGE_KEY_FLOATING_BALL_SIZE = "codeg-floating-ball-size" +export const STORAGE_KEY_FLOATING_BALL_FAVORITE_AGENTS = + "codeg-floating-ball-favorite-agents" + // 字体偏好(界面 / 编辑器 / 终端)。 // 只有界面字体需要 *_STACK(已解析的 CSS font-family 栈),供 inline 脚本零依赖地 // 预水合写入 --font-sans;编辑器/终端字体只走各自的 Monaco/xterm 选项,水合后才挂载, diff --git a/src/lib/default-mode-prefs.ts b/src/lib/default-mode-prefs.ts new file mode 100644 index 0000000000..b78fc61d23 --- /dev/null +++ b/src/lib/default-mode-prefs.ts @@ -0,0 +1,32 @@ +/** + * Classification helper shared by the floating ball's "Plan ⇄ full access" + * toggle: identify the full-access mode among an agent's advertised mode ids. + * Each CLI calls full access something else (zcode `yolo`, codex + * `danger-full-access`, claude `bypassPermissions`), so the match runs a fixed + * weight order of well-known substrings — no blind id guessing, the backend + * would silently drop a wrong preferred id. + */ + +/** Substrings that identify a full-access mode, in match priority order. */ +export const FULL_ACCESS_WEIGHTS = [ + "danger", + "bypass", + "yolo", + "full", + "access", +] as const + +/** + * Match a full-access mode among the advertised ids. Weight order decides + * between multiple hits (an agent offering both `yolo` and + * `danger-full-access` gets the `danger` one); ties resolve by list order so + * the result is stable across restarts. + */ +export function matchFullAccessMode(modeIds: readonly string[]): string | null { + for (const weight of FULL_ACCESS_WEIGHTS) { + for (const id of modeIds) { + if (id.toLowerCase().includes(weight)) return id + } + } + return null +} diff --git a/src/lib/floating-ball-gestures.test.ts b/src/lib/floating-ball-gestures.test.ts new file mode 100644 index 0000000000..80319b98d5 --- /dev/null +++ b/src/lib/floating-ball-gestures.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest" + +import { + createFloatingBallTracker, + DEFAULT_FLOATING_BALL_BINDINGS, + FLOATING_BALL_GESTURES, + LONG_PRESS_MS, + MOVE_PX, + resolveTapGesture, + SWIPE_PX, + type FloatingBallGesture, +} from "./floating-ball-gestures" + +/** Walk a straight line in `n` samples, the way a finger reports positions. */ +function steps( + from: [number, number], + to: [number, number], + n = 8 +): [number, number][] { + const points: [number, number][] = [] + for (let i = 1; i <= n; i += 1) { + points.push([ + from[0] + ((to[0] - from[0]) * i) / n, + from[1] + ((to[1] - from[1]) * i) / n, + ]) + } + return points +} + +/** Run a trail of legs through the tracker and return what it names. */ +function trail(...legs: [number, number][]): FloatingBallGesture | null { + const points: [number, number][] = [[0, 0]] + for (const leg of legs) { + points.push(...steps(points[points.length - 1], leg)) + } + return run(points) +} + +function run(points: [number, number][]): FloatingBallGesture | null { + const tracker = createFloatingBallTracker() + tracker.start(points[0][0], points[0][1]) + for (const point of points.slice(1)) tracker.move(point[0], point[1]) + return tracker.end() +} + +const S = SWIPE_PX + +describe("single-stroke gestures", () => { + it("names each cardinal and upward diagonal", () => { + expect( + run([ + [0, 0], + [-(S + 20), 0], + ]) + ).toBe("left") + expect( + run([ + [0, 0], + [S + 20, 0], + ]) + ).toBe("right") + expect( + run([ + [0, 0], + [0, -(S + 20)], + ]) + ).toBe("up") + expect( + run([ + [0, 0], + [-(S + 10), -(S + 10)], + ]) + ).toBe("upLeft") + expect( + run([ + [0, 0], + [S + 10, -(S + 10)], + ]) + ).toBe("upRight") + }) + + it("names downward strokes as first-class gestures", () => { + // Batch v82: down/downLeft/downRight are recognisable now (binding them is + // the bindings table's job, not the recogniser's). + expect( + run([ + [0, 0], + [0, S + 20], + ]) + ).toBe("down") + expect( + run([ + [0, 0], + [-(S + 10), S + 10], + ]) + ).toBe("downLeft") + expect( + run([ + [0, 0], + [S + 10, S + 10], + ]) + ).toBe("downRight") + }) + + it("ignores movement below the threshold", () => { + expect( + run([ + [0, 0], + [MOVE_PX - 3, 3], + ]) + ).toBeNull() + }) + + it("needs one axis to dominate before naming a direction", () => { + // 45 across vs 20 down is 2.25x: clearly a horizontal stroke. + expect( + run([ + [0, 0], + [S + 15, 20], + ]) + ).toBe("right") + // 30 vs 20 is only 1.5x and neither axis reaches the diagonal threshold, + // so the stroke stays ambiguous and fires nothing rather than guessing. + expect( + run([ + [0, 0], + [S, 20], + ]) + ).toBeNull() + }) +}) + +describe("two-stage trails", () => { + it("folds a return stroke into its round-trip gesture", () => { + expect(trail([-50, 0], [50, 0])).toBe("leftThenRight") + expect(trail([50, 0], [-50, 0])).toBe("rightThenLeft") + expect(trail([0, -50], [0, 50])).toBe("upDown") + expect(trail([0, 50], [0, -50])).toBe("downUp") + }) + + it("folds an upward start plus a turn", () => { + expect(trail([0, -50], [-60, -50])).toBe("upThenLeft") + expect(trail([0, -50], [60, -50])).toBe("upThenRight") + expect(trail([-40, -40], [-40, 40])).toBe("upLeftThenDown") + expect(trail([40, -40], [40, 40])).toBe("upRightThenDown") + expect(trail([0, -50], [S + 10, S + 10])).toBe("upThenDownRight") + }) + + it("folds the batch-v82 downward and horizontal starts", () => { + // Each of these pairs previously fell through the jitter-replacement rule + // and silently became a different single-stage gesture. + expect(trail([0, 50], [S + 10, -(S + 10)])).toBe("downThenUpRight") + expect(trail([50, 0], [50, -(S + 20)])).toBe("rightThenUp") + expect(trail([50, 0], [50, S + 20])).toBe("rightThenDown") + expect(trail([0, 50], [S + 20, 50])).toBe("downThenRight") + expect(trail([0, 50], [-(S + 20), 50])).toBe("downThenLeft") + expect(trail([S + 10, S + 10], [S + 10, -(S + 20)])).toBe("downRightThenUp") + expect(trail([-(S + 10), S + 10], [-(S + 10), -(S + 20)])).toBe( + "downLeftThenUp" + ) + }) + + it("keeps a round trip whose finger lands back at the origin", () => { + // Regression (batch v82): end() used to require displacement from the + // origin, which dropped a ↓↑ drawn cleanly back to its start. + const points: [number, number][] = [ + ...steps([0, 0], [0, 60]), + ...steps([0, 60], [0, 0]), + ] + expect(run(points)).toBe("downUp") + const upDown: [number, number][] = [ + ...steps([0, 0], [0, -60]), + ...steps([0, -60], [0, 0]), + ] + expect(run(upDown)).toBe("upDown") + }) + + it("does not read a horizontal round trip as a stop gesture", () => { + // Regression: summing displacement from the origin cancelled ←→ into a + // diagonal, which fired the stop binding. + expect(trail([-50, 0], [50, 0])).not.toBe("upRightThenDown") + }) + + it("records the stages it saw", () => { + const tracker = createFloatingBallTracker() + tracker.start(0, 0) + for (const point of steps([0, 0], [0, -60])) tracker.move(...point) + for (const point of steps([0, -60], [0, 60])) tracker.move(...point) + expect(tracker.trail).toEqual(["U", "D"]) + }) +}) + +describe("click-scale gestures", () => { + it("separates a tap, a double tap and a long press", () => { + expect(resolveTapGesture(80, null)).toBe("tap") + expect(resolveTapGesture(80, 120)).toBe("doubleTap") + expect(resolveTapGesture(LONG_PRESS_MS, null)).toBe("longPress") + expect(resolveTapGesture(LONG_PRESS_MS, 50)).toBe("longPress") + expect(resolveTapGesture(80, 400)).toBe("tap") + }) +}) + +describe("default bindings", () => { + it("binds every gesture, allowing deliberate gaps", () => { + const unbound = FLOATING_BALL_GESTURES.filter( + (gesture) => DEFAULT_FLOATING_BALL_BINDINGS[gesture] === null + ) + // The long corner continuations are the only intentional gaps: a trail + // passing through a two-stage binding would otherwise fire twice. Batch + // v86 gave the straight down family the aux-panel bindings, so nothing + // recognisable is left idle except the corner continuations that must + // stay unreachable. + expect(unbound.sort()).toEqual(["upLeftThenRight", "upRightThenLeft"]) + }) + + it("keeps the MindFS muscle memory for the headline gestures", () => { + expect(DEFAULT_FLOATING_BALL_BINDINGS.left).toBe("newConversation") + expect(DEFAULT_FLOATING_BALL_BINDINGS.tap).toBe("panel") + expect(DEFAULT_FLOATING_BALL_BINDINGS.longPress).toBe("openSettings") + expect(DEFAULT_FLOATING_BALL_BINDINGS.upRightThenDown).toBe( + "stopGenerating" + ) + }) + + it("gives the right sidebar the down family and double tap", () => { + // Batch v86: the aux panel is the phone's newest high-traffic surface. + // Double tap moves off the terminal onto the panel toggle, and the two + // downward diagonals open its most-read tabs (↓ itself is the toggle; + // openGitLog stays unbound — three bindings on one family leave no + // unambiguous stroke for it, and the panel rail reaches it in one tap). + expect(DEFAULT_FLOATING_BALL_BINDINGS.down).toBe("toggleAuxPanel") + expect(DEFAULT_FLOATING_BALL_BINDINGS.doubleTap).toBe("toggleAuxPanel") + expect(DEFAULT_FLOATING_BALL_BINDINGS.downLeft).toBe("openChanges") + expect(DEFAULT_FLOATING_BALL_BINDINGS.downRight).toBe("openSessionDetails") + }) +}) diff --git a/src/lib/floating-ball-gestures.ts b/src/lib/floating-ball-gestures.ts new file mode 100644 index 0000000000..d24a73b337 --- /dev/null +++ b/src/lib/floating-ball-gestures.ts @@ -0,0 +1,400 @@ +/** + * Gesture vocabulary for the workspace floating ball. + * + * Pure — no DOM, no timers — so the recogniser can be exercised directly and + * the component above it stays a thin view. The vocabulary and thresholds are + * carried over from the MindFS blue-ring menu (`quickSwitch.ts`) so the muscle + * memory of that app transfers unchanged. + * + * Two families: + * - click-scale: tap / doubleTap / longPress, decided on duration alone. + * - path gestures: one-stroke swipes (left/right/up and the two upward + * diagonals), two-stage trails that start upward and turn, and round-trip + * trails that come back on themselves. + * + * Path tracking is anchor-based, not displacement-from-origin based: each time + * the direction changes, the anchor moves to the finger. Summing displacement + * from the origin makes a ↑ then ↓ trail cancel out into a single straight + * stroke, which is how the original implementation mistook "upDown" for a + * diagonal. + */ + +export type FloatingBallGesture = + | "tap" + | "doubleTap" + | "longPress" + | "left" + | "right" + | "up" + | "down" + | "upLeft" + | "upRight" + | "downLeft" + | "downRight" + | "upThenLeft" + | "upThenRight" + | "upLeftThenRight" + | "upRightThenLeft" + | "upRightThenDown" + | "upLeftThenDown" + | "upDown" + | "downUp" + | "upThenDownRight" + | "downThenUpRight" + | "rightThenUp" + | "rightThenDown" + | "downThenRight" + | "downThenLeft" + | "downRightThenUp" + | "downLeftThenUp" + | "rightThenLeft" + | "leftThenRight" + +export type FloatingBallAction = + | "panel" + | "newConversation" + | "toggleSidebar" + | "toggleAuxPanel" + | "toggleSearch" + | "toggleTerminal" + | "nextTab" + | "prevTab" + | "reopenClosedTab" + | "openFolder" + | "zoomIn" + | "zoomOut" + | "zoomReset" + | "stopGenerating" + | "openSettings" + | "openTodos" + | "openTodosNew" + | "openAutomations" + | "openCanvas" + | "openAgents" + | "openSystemAdmin" + | "openSessionDetails" + | "openChanges" + | "openGitLog" + | "renameConversation" + | "openSessionFolder" + | "cycleAgent" + | "cycleFavoriteAgent" + | "openBallBindings" + | "cycleRunningSessions" + | "cycleRecentSessions" + | "deleteCurrentConversation" + | "openImportSessions" + | "togglePinConversation" + | "focusModeSelector" + | "togglePlanFullAccess" + | "openCollaboration" + +export const FLOATING_BALL_GESTURES: readonly FloatingBallGesture[] = [ + "tap", + "doubleTap", + "longPress", + "left", + "right", + "up", + "down", + "upLeft", + "upRight", + "downLeft", + "downRight", + "upThenLeft", + "upThenRight", + "upLeftThenRight", + "upRightThenLeft", + "upRightThenDown", + "upLeftThenDown", + "upDown", + "downUp", + "upThenDownRight", + "downThenUpRight", + "rightThenUp", + "rightThenDown", + "downThenRight", + "downThenLeft", + "downRightThenUp", + "downLeftThenUp", + "rightThenLeft", + "leftThenRight", +] + +/** + * What each gesture does out of the box. + * + * `upLeftThenRight` and `upRightThenLeft` are deliberately unbound: the trails + * that reach them pass through the two-stage bindings above, so a stray + * continuation would fire a second action on the way. + * + * The ten batch-v82 actions spread across the new downward / compound strokes; + * `stopGenerating` stays on an inconspicuous compound on purpose — a gesture + * that aborts a running answer must not live next to the ones fingers find + * first. + * + * Batch v86 adds the right sidebar (aux panel) family: `toggleAuxPanel` takes + * the one unbound single stroke (↓), and the panel's three high-frequency tabs + * take the downward diagonals. Double tap moves from the terminal to the aux + * panel — the terminal keeps a bound gesture (↑), while before v0.38.0 the + * right panel had no ball action at all and is the newer, higher-traffic + * surface on the phone. + */ +export const DEFAULT_FLOATING_BALL_BINDINGS: Readonly< + Record +> = { + tap: "panel", + doubleTap: "toggleAuxPanel", + longPress: "openSettings", + left: "newConversation", + right: "toggleSidebar", + up: "panel", + down: "toggleAuxPanel", + upLeft: "toggleSearch", + upRight: "nextTab", + downLeft: "openChanges", + downRight: "openSessionDetails", + upThenLeft: "prevTab", + upThenRight: "reopenClosedTab", + upLeftThenRight: null, + upRightThenLeft: null, + upRightThenDown: "stopGenerating", + upLeftThenDown: "zoomOut", + upDown: "openFolder", + downUp: "cycleAgent", + upThenDownRight: "openCanvas", + downThenUpRight: "cycleFavoriteAgent", + rightThenUp: "openTodos", + rightThenDown: "openTodosNew", + downThenRight: "openAutomations", + downThenLeft: "openSessionFolder", + downRightThenUp: "openAgents", + downLeftThenUp: "openSystemAdmin", + rightThenLeft: "zoomReset", + leftThenRight: "zoomIn", +} + +/** A press held at least this long is a long press, not a tap. */ +export const LONG_PRESS_MS = 600 +/** A second press within this window of the first makes it a double tap. */ +export const DOUBLE_TAP_MS = 300 +/** + * Movement faster than this after touchdown is a gesture; slower is a drag of + * the ball itself. Both start with the same pointer, so time is what tells + * them apart. + */ +export const GESTURE_ONSET_MS = 250 +/** Below this displacement nothing counts as movement at all. */ +export const MOVE_PX = 8 +/** A single stroke must reach this far on an axis to name a direction. */ +export const SWIPE_PX = 30 +/** Reverse travel within one stage that starts a new stage. */ +export const SEGMENT_GAP_PX = 22 + +type Segment = "L" | "R" | "U" | "D" | "UL" | "UR" | "DL" | "DR" + +const OPPOSITE: Record = { + L: "R", + R: "L", + U: "D", + D: "U", + UL: "DR", + UR: "DL", + DL: "UR", + DR: "UL", +} + +function isOpposite(a: Segment | null, b: Segment): boolean { + return a !== null && OPPOSITE[a] === b +} + +/** + * Name the direction of one stage, or null while it is still too short. + * + * A stage that is long on both axes is a diagonal; otherwise the longer axis + * wins, and only when it dominates by 1.6x — a nearly square 30x28 stroke is + * still "up", not "upLeft". + */ +function classifySegment(dx: number, dy: number): Segment | null { + const adx = Math.abs(dx) + const ady = Math.abs(dy) + if (adx >= SWIPE_PX && ady >= SWIPE_PX) { + if (dy < 0) return dx < 0 ? "UL" : "UR" + return dx < 0 ? "DL" : "DR" + } + if (adx >= SWIPE_PX && adx > ady * 1.6) return dx < 0 ? "L" : "R" + if (ady >= SWIPE_PX && ady > adx * 1.6) return dy < 0 ? "U" : "D" + return null +} + +/** The last two stages of a trail, folded into the gesture they spell. */ +function foldTrail(path: readonly Segment[]): FloatingBallGesture | null { + if (path.length < 2) return null + const a = path[path.length - 2] + const b = path[path.length - 1] + if (a === "L" && b === "R") return "leftThenRight" + if (a === "R" && b === "L") return "rightThenLeft" + if (a === "U" && b === "D") return "upDown" + if (a === "D" && b === "U") return "downUp" + if (a === "U" && b === "L") return "upThenLeft" + if (a === "U" && b === "R") return "upThenRight" + if (a === "U" && b === "DR") return "upThenDownRight" + if (a === "D" && b === "UR") return "downThenUpRight" + if (a === "R" && b === "U") return "rightThenUp" + if (a === "R" && b === "D") return "rightThenDown" + if (a === "D" && b === "R") return "downThenRight" + if (a === "D" && b === "L") return "downThenLeft" + if (a === "DR" && b === "U") return "downRightThenUp" + if (a === "DL" && b === "U") return "downLeftThenUp" + if (a === "UL" && b === "R") return "upLeftThenRight" + if (a === "UR" && b === "L") return "upRightThenLeft" + if (a === "UR" && b === "D") return "upRightThenDown" + if (a === "UL" && b === "D") return "upLeftThenDown" + return null +} + +/** A single stage as a gesture. Downward strokes are bound via the table. */ +function singleStage(stage: Segment): FloatingBallGesture | null { + switch (stage) { + case "L": + return "left" + case "R": + return "right" + case "U": + return "up" + case "D": + return "down" + case "UL": + return "upLeft" + case "UR": + return "upRight" + case "DL": + return "downLeft" + case "DR": + return "downRight" + default: + return null + } +} + +/** + * Name the gesture the trail would commit right now, loose-matched — the hint + * pill's source of truth. Mirrors the tracker's own folding, so the pill and + * the commit can never disagree. An unfinished two-stage trail falls back to + * its last stage so the pill shows what a stop right there would fire. + */ +export function previewGesture( + trail: readonly Segment[] +): FloatingBallGesture | null { + if (trail.length === 0) return null + const folded = foldTrail(trail) + if (folded) return folded + return singleStage(trail[trail.length - 1]) +} + +export interface FloatingBallTracker { + /** Touchdown. */ + start(x: number, y: number): void + /** Every pointer sample while the press is down. */ + move(x: number, y: number): void + /** Lift-off: the path gesture, or null when this press was not one. */ + end(): FloatingBallGesture | null + /** Whether the press has travelled far enough to be a gesture at all. */ + readonly moved: boolean + /** Stages recognised so far, oldest first. Exposed for tests and debugging. */ + readonly trail: readonly Segment[] + reset(): void +} + +export function createFloatingBallTracker(): FloatingBallTracker { + let originX = 0 + let originY = 0 + let anchorX = 0 + let anchorY = 0 + let moved = false + let trail: Segment[] = [] + + const reset = () => { + originX = 0 + originY = 0 + anchorX = 0 + anchorY = 0 + moved = false + trail = [] + } + + return { + get moved() { + return moved + }, + get trail() { + return trail + }, + reset, + start(x, y) { + reset() + originX = x + originY = y + anchorX = x + anchorY = y + }, + move(x, y) { + const total = Math.hypot(x - originX, y - originY) + if (!moved && total <= MOVE_PX) return + moved = true + + const stage = classifySegment(x - anchorX, y - anchorY) + if (!stage) return + const last = trail.length > 0 ? trail[trail.length - 1] : null + if (stage === last) return + + if (isOpposite(last, stage) || trail.length === 0) { + // A reversal always starts a new stage; so does the first one. + trail.push(stage) + } else if (trail.length === 1 && foldTrail([trail[0], stage]) === null) { + // Jitter at the start of a stroke (U that drifts into UL) replaces the + // opening stage — unless the pair spells a binding, in which case the + // second stage must be kept or the gesture is lost. + trail[0] = stage + } else { + trail.push(stage) + } + // The next stage is measured from here, so it does not inherit the + // travel that already named this one. + anchorX = x + anchorY = y + }, + end(): FloatingBallGesture | null { + if (!moved) return null + // A completed trail is judged on its stages, not on how far the finger + // ended from the origin: round trips (↓↑, ↑↓) naturally land back where + // they started, and an origin-displacement check would drop precisely + // the strokes drawn most cleanly. + if (trail.length === 0) return null + + const folded = foldTrail(trail) + if (folded) return folded + + if (trail.length === 1) return singleStage(trail[0]) + return null + }, + } +} + +/** + * Which click-scale gesture a completed press is. + * + * `msSinceLastTap` is the gap to the previous tap on the ball, or null when + * there is none pending. A press that produced a long press or a double tap + * clears that pending tap; a plain tap starts the window. + */ +export function resolveTapGesture( + heldMs: number, + msSinceLastTap: number | null +): "longPress" | "doubleTap" | "tap" { + if (heldMs >= LONG_PRESS_MS) return "longPress" + if (msSinceLastTap !== null && msSinceLastTap <= DOUBLE_TAP_MS) { + return "doubleTap" + } + return "tap" +} diff --git a/src/lib/floating-ball-session-events.test.ts b/src/lib/floating-ball-session-events.test.ts new file mode 100644 index 0000000000..ecea63d6ef --- /dev/null +++ b/src/lib/floating-ball-session-events.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, vi, beforeEach } from "vitest" +import { + requestDeleteConversation, + requestFocusModeSelector, + requestTogglePlanFull, + eventMatchesConversation, + DELETE_CONVERSATION_EVENT, + FOCUS_MODE_SELECTOR_EVENT, + TOGGLE_PLAN_FULL_EVENT, +} from "./floating-ball-session-events" + +describe("floating-ball-session-events", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("dispatch carries the conversation id on the right event type", () => { + const seen: Array<{ type: string; id: number | null }> = [] + const onDelete = (e: Event) => + seen.push({ + type: e.type, + id: (e as CustomEvent<{ conversationId: number | null }>).detail + .conversationId, + }) + window.addEventListener(DELETE_CONVERSATION_EVENT, onDelete) + window.addEventListener(FOCUS_MODE_SELECTOR_EVENT, onDelete) + window.addEventListener(TOGGLE_PLAN_FULL_EVENT, onDelete) + + requestDeleteConversation(7) + requestFocusModeSelector(null) + requestTogglePlanFull(9) + + window.removeEventListener(DELETE_CONVERSATION_EVENT, onDelete) + window.removeEventListener(FOCUS_MODE_SELECTOR_EVENT, onDelete) + window.removeEventListener(TOGGLE_PLAN_FULL_EVENT, onDelete) + + expect(seen).toEqual([ + { type: DELETE_CONVERSATION_EVENT, id: 7 }, + { type: FOCUS_MODE_SELECTOR_EVENT, id: null }, + { type: TOGGLE_PLAN_FULL_EVENT, id: 9 }, + ]) + }) + + it("eventMatchesConversation filters by id (multi-tab safety)", () => { + expect(eventMatchesConversation({ conversationId: 3 }, 3)).toBe(true) + expect(eventMatchesConversation({ conversationId: 3 }, 4)).toBe(false) + // Draft (null) events only match draft instances. + expect(eventMatchesConversation({ conversationId: null }, null)).toBe(true) + expect(eventMatchesConversation({ conversationId: null }, 3)).toBe(false) + expect(eventMatchesConversation(undefined, 3)).toBe(false) + }) +}) diff --git a/src/lib/floating-ball-session-events.ts b/src/lib/floating-ball-session-events.ts new file mode 100644 index 0000000000..a72999d588 --- /dev/null +++ b/src/lib/floating-ball-session-events.ts @@ -0,0 +1,47 @@ +/** + * Floating-ball hand-off events for actions that must run inside a mounted + * conversation view (which owns the mode selector, pin state and the delete + * confirm dialog). Each event carries the DB conversation id and each + * per-tab instance compares it against its own — a tab whose conversation + * does not match ignores the event (multi-tab safe, same idiom as + * rename-conversation-events but synchronous). + */ + +export const DELETE_CONVERSATION_EVENT = "codeg:fb-delete-conversation" +export const FOCUS_MODE_SELECTOR_EVENT = "codeg:fb-focus-mode-selector" +export const TOGGLE_PLAN_FULL_EVENT = "codeg:fb-toggle-plan-full" + +export interface FloatingBallSessionDetail { + conversationId: number | null +} + +function dispatch(type: string, conversationId: number | null) { + window.dispatchEvent( + new CustomEvent(type, { + detail: { conversationId } satisfies FloatingBallSessionDetail, + }) + ) +} + +/** Open the delete confirmation for the current session (header-owned UI). */ +export function requestDeleteConversation(conversationId: number | null) { + dispatch(DELETE_CONVERSATION_EVENT, conversationId) +} + +/** Focus/open the composer's inline mode selector on the active tab. */ +export function requestFocusModeSelector(conversationId: number | null) { + dispatch(FOCUS_MODE_SELECTOR_EVENT, conversationId) +} + +/** Switch the active session between Plan and full-access mode. */ +export function requestTogglePlanFull(conversationId: number | null) { + dispatch(TOGGLE_PLAN_FULL_EVENT, conversationId) +} + +/** True when the event targets this instance's conversation (null = drafts). */ +export function eventMatchesConversation( + detail: FloatingBallSessionDetail | undefined, + conversationId: number | null +): boolean { + return detail?.conversationId === conversationId +} diff --git a/src/lib/recent-sessions-prefs.test.ts b/src/lib/recent-sessions-prefs.test.ts new file mode 100644 index 0000000000..a4e59ba6c5 --- /dev/null +++ b/src/lib/recent-sessions-prefs.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, beforeEach } from "vitest" +import { + rememberRecentConversation, + forgetRecentConversation, + nextRecentConversation, + readRecentSessionDepth, + writeRecentSessionDepth, + DEFAULT_RECENT_SESSION_DEPTH, +} from "./recent-sessions-prefs" + +describe("recent-sessions-prefs", () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it("rememberRecentConversation dedupes and puts most recent first", () => { + rememberRecentConversation(1) + rememberRecentConversation(2) + rememberRecentConversation(1) + expect(nextRecentConversation(null, 4, () => true)).toBe(1) + // After re-remembering 1, the next candidate (not active) is 2. + rememberRecentConversation(1) + expect(nextRecentConversation(1, 4, () => true)).toBe(2) + }) + + it("caps the queue at 8 entries", () => { + for (let i = 1; i <= 12; i++) rememberRecentConversation(i) + // Only ids 5..12 survive. + for (let i = 1; i <= 4; i++) { + expect(nextRecentConversation(null, 8, (id) => id === i)).toBeNull() + } + expect(nextRecentConversation(null, 8, (id) => id === 5)).toBe(5) + }) + + it("nextRecentConversation respects depth - 1 candidates", () => { + rememberRecentConversation(1) + rememberRecentConversation(2) + rememberRecentConversation(3) + // Queue is [3,2,1]. Depth 2 → one candidate (3). Excluding active 3 → 2. + expect(nextRecentConversation(3, 2, () => true)).toBe(2) + // Depth 4 → candidates [3,2,1]; excluding active 2 → first is 3. + expect(nextRecentConversation(2, 4, () => true)).toBe(3) + }) + + it("nextRecentConversation filters unavailable ids", () => { + rememberRecentConversation(1) + rememberRecentConversation(2) + expect(nextRecentConversation(null, 4, (id) => id !== 2)).toBe(1) + expect(nextRecentConversation(null, 4, () => false)).toBeNull() + }) + + it("forgetRecentConversation removes a deleted conversation", () => { + rememberRecentConversation(1) + rememberRecentConversation(2) + forgetRecentConversation(1) + expect(nextRecentConversation(2, 4, () => true)).toBeNull() + expect(nextRecentConversation(1, 4, () => true)).toBe(2) + }) + + it("depth defaults to 2 and only accepts 3/4 overrides", () => { + expect(readRecentSessionDepth()).toBe(DEFAULT_RECENT_SESSION_DEPTH) + writeRecentSessionDepth(4) + expect(readRecentSessionDepth()).toBe(4) + writeRecentSessionDepth(3) + expect(readRecentSessionDepth()).toBe(3) + }) + + it("survives corrupted storage", () => { + window.localStorage.setItem("codeg:recent-conversations", "not-json{") + expect(nextRecentConversation(null, 4, () => true)).toBeNull() + window.localStorage.setItem( + "codeg:recent-conversations", + JSON.stringify(["a", 5, null, 7]) + ) + expect(nextRecentConversation(null, 4, () => true)).toBe(5) + }) +}) diff --git a/src/lib/recent-sessions-prefs.ts b/src/lib/recent-sessions-prefs.ts new file mode 100644 index 0000000000..2b48dbe5ad --- /dev/null +++ b/src/lib/recent-sessions-prefs.ts @@ -0,0 +1,92 @@ +/** + * "Recent sessions" LRU queue + switch-depth preference, backing the floating + * ball's cycle-recent-sessions gesture. + * + * The queue lives in localStorage (shared across windows of the same origin) + * and stores conversationIds only; consumers must filter to conversations + * their own window can actually open. Depth is 2-4 (default 2): with depth 2 + * the gesture toggles between the two most recently opened conversations. + */ +const STORAGE_KEY_RECENT_CONVERSATIONS = "codeg:recent-conversations" +const STORAGE_KEY_RECENT_SESSION_DEPTH = "codeg:recent-session-depth" + +/** Queue holds more than the max depth so switching survives a bit of drift. */ +const QUEUE_CAPACITY = 8 +export const MIN_RECENT_SESSION_DEPTH = 2 +export const MAX_RECENT_SESSION_DEPTH = 4 +export const DEFAULT_RECENT_SESSION_DEPTH = 2 + +export type RecentSessionDepth = 2 | 3 | 4 + +function readQueue(): number[] { + if (typeof window === "undefined") return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY_RECENT_CONVERSATIONS) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (id): id is number => typeof id === "number" && Number.isFinite(id) + ) + } catch { + return [] + } +} + +function writeQueue(queue: number[]): void { + if (typeof window === "undefined") return + try { + window.localStorage.setItem( + STORAGE_KEY_RECENT_CONVERSATIONS, + JSON.stringify(queue.slice(0, QUEUE_CAPACITY)) + ) + } catch { + // Storage full or unavailable: cycling just becomes a no-op, acceptable. + } +} + +/** Push a conversation as "just opened", deduped, most-recent first. */ +export function rememberRecentConversation(id: number): void { + const next = [id, ...readQueue().filter((v) => v !== id)] + writeQueue(next) +} + +/** Drop a deleted conversation so cycling never lands on it. */ +export function forgetRecentConversation(id: number): void { + const next = readQueue().filter((v) => v !== id) + if (next.length !== readQueue().length) writeQueue(next) +} + +/** + * Pick the next conversation to switch to: the most recent entry that isn't + * the active one, within the configured depth window. Returns null when there + * is nothing to switch to. Moving the picked id to the front is what makes + * repeated gestures toggle between two sessions at depth 2. + */ +export function nextRecentConversation( + activeId: number | null, + depth: number, + isAvailable: (id: number) => boolean +): number | null { + const candidates = readQueue() + .filter((id) => id !== activeId && isAvailable(id)) + .slice(0, Math.max(0, depth - 1)) + return candidates.length > 0 ? candidates[0] : null +} + +export function readRecentSessionDepth(): RecentSessionDepth { + if (typeof window === "undefined") return DEFAULT_RECENT_SESSION_DEPTH + const raw = window.localStorage.getItem(STORAGE_KEY_RECENT_SESSION_DEPTH) + return raw === "3" || raw === "4" + ? (Number(raw) as RecentSessionDepth) + : DEFAULT_RECENT_SESSION_DEPTH +} + +export function writeRecentSessionDepth(depth: RecentSessionDepth): void { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(STORAGE_KEY_RECENT_SESSION_DEPTH, String(depth)) + } catch { + // Unavailable storage: the selector will read the default back. + } +} diff --git a/src/lib/rename-conversation-events.ts b/src/lib/rename-conversation-events.ts new file mode 100644 index 0000000000..e9956e9a8a --- /dev/null +++ b/src/lib/rename-conversation-events.ts @@ -0,0 +1,17 @@ +/** + * "Rename the current session" hand-off from the floating ball. The header + * that owns the rename dialog is mounted per conversation tab, so the signal + * carries the DB id and each instance compares it against its own — a tab + * whose conversation does not match ignores the event (multi-tab safe, same + * idiom as task-compose-events but synchronous: the header stays mounted). + */ + +export const RENAME_CONVERSATION_EVENT = "codeg:rename-conversation" + +export interface RenameConversationDetail { + conversationId: number +} + +export function requestRenameConversation(detail: RenameConversationDetail) { + window.dispatchEvent(new CustomEvent(RENAME_CONVERSATION_EVENT, { detail })) +} diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index ba806fe86b..6cb2a2cc58 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -36,6 +36,7 @@ import { sweepOrphanDraftKeys, } from "@/lib/message-input-draft" import { discardAskSelectionPrompts } from "@/lib/ask-selection-handoff" +import { rememberRecentConversation } from "@/lib/recent-sessions-prefs" import { batchCloseSlots, pushClosedTab, @@ -1056,6 +1057,13 @@ function focusTab(tabId: string) { if (useTabStore.getState().activeTabId !== tabId) { useTabStore.setState({ activeTabId: tabId }) } + // Every activation path funnels through here, so this is the one place the + // floating ball's "recent sessions" LRU needs to record a conversation as + // "just opened" (bound tabs only; drafts have no DB row yet). + const tab = useTabStore.getState().rawTabs.find((t) => t.id === tabId) + if (tab?.conversationId != null) { + rememberRecentConversation(tab.conversationId) + } applyGroupInvariants() } @@ -2101,6 +2109,11 @@ export const useTabStore = create()((set, get) => ({ ? prevState.activeTabId : (boundTab?.id ?? nextTabs[0]?.id ?? null), }) + // A draft becomes a real session the first time it persists a DB row — + // that is its "first open" for the recent-sessions LRU. + if (conversationId != null) { + rememberRecentConversation(conversationId) + } recomputeTabs() },