| author | |
| committer | |
| log | fe86836b43dbcb49598d8e7a728c7febb9584b3c |
| tree | f7266f24c8d482c96db5f97bd334b89b2ed53f64 |
| parent | 973e62373e5b442ac9c13e0e65f771132c5e66d4 |
| parent | 73fa2d66c79eb1351fbae55a81c61e1f7b88c619 |
Co-authored-by: codex <codex@users.noreply.github.com>34 files changed, 2887 insertions(+), 661 deletions(-)
KEYBINDINGS.md created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | # Keybindings | |
| 2 | ||
| 3 | T3 Code reads keybindings from: | |
| 4 | ||
| 5 | - `~/.t3/keybindings.json` | |
| 6 | ||
| 7 | The file must be a JSON array of rules: | |
| 8 | ||
| 9 | ```json | |
| 10 | [ | |
| 11 | { "key": "mod+g", "command": "terminal.toggle" }, | |
| 12 | { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" } | |
| 13 | ] | |
| 14 | ``` | |
| 15 | ||
| 16 | See the full schema for more details: [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) | |
| 17 | ||
| 18 | ## Defaults | |
| 19 | ||
| 20 | ```json | |
| 21 | [ | |
| 22 | { "key": "mod+j", "command": "terminal.toggle" }, | |
| 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, | |
| 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, | |
| 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, | |
| 26 | { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, | |
| 27 | { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, | |
| 28 | { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, | |
| 29 | { "key": "mod+o", "command": "editor.openFavorite" } | |
| 30 | ] | |
| 31 | ``` | |
| 32 | ||
| 33 | For most up to date defaults, see [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) | |
| 34 | ||
| 35 | ## Configuration | |
| 36 | ||
| 37 | ### Rule Shape | |
| 38 | ||
| 39 | Each entry supports: | |
| 40 | ||
| 41 | - `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` | |
| 42 | - `command` (required): action ID | |
| 43 | - `when` (optional): boolean expression controlling when the shortcut is active | |
| 44 | ||
| 45 | Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server. | |
| 46 | ||
| 47 | ### Available Commands | |
| 48 | ||
| 49 | - `terminal.toggle`: open/close terminal drawer | |
| 50 | - `terminal.split`: split terminal (in focused terminal context by default) | |
| 51 | - `terminal.new`: create new terminal (in focused terminal context by default) | |
| 52 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) | |
| 53 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state | |
| 54 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) | |
| 55 | - `editor.openFavorite`: open current project/worktree in the last-used editor | |
| 56 | ||
| 57 | ### Key Syntax | |
| 58 | ||
| 59 | Supported modifiers: | |
| 60 | ||
| 61 | - `mod` (`cmd` on macOS, `ctrl` on non-macOS) | |
| 62 | - `cmd` / `meta` | |
| 63 | - `ctrl` / `control` | |
| 64 | - `shift` | |
| 65 | - `alt` / `option` | |
| 66 | ||
| 67 | Examples: | |
| 68 | ||
| 69 | - `mod+j` | |
| 70 | - `mod+shift+d` | |
| 71 | - `ctrl+l` | |
| 72 | - `cmd+k` | |
| 73 | ||
| 74 | ### `when` Conditions | |
| 75 | ||
| 76 | Currently available context keys: | |
| 77 | ||
| 78 | - `terminalFocus` | |
| 79 | - `terminalOpen` | |
| 80 | ||
| 81 | Supported operators: | |
| 82 | ||
| 83 | - `!` (not) | |
| 84 | - `&&` (and) | |
| 85 | - `||` (or) | |
| 86 | - parentheses: `(` `)` | |
| 87 | ||
| 88 | Examples: | |
| 89 | ||
| 90 | - `"when": "terminalFocus"` | |
| 91 | - `"when": "terminalOpen && !terminalFocus"` | |
| 92 | - `"when": "terminalFocus || terminalOpen"` | |
| 93 | ||
| 94 | Unknown condition keys evaluate to `false`. | |
| 95 | ||
| 96 | ### Precedence | |
| 97 | ||
| 98 | - Rules are evaluated in array order. | |
| 99 | - For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins. | |
| 100 | - That means precedence is across commands, not only within the same command. |
apps/server/src/gitManager.test.ts+14-15| ... | ... | @@ -190,15 +190,15 @@ describe("GitManager", () => { |
| 190 | 190 | |
| 191 | 191 | const status = await manager.status({ cwd: repoDir }); |
| 192 | 192 | expect(status.branch).toBe("feature/status-open-pr"); |
| 193 | expect(status.openPr).toEqual({ | |
| 193 | expect(status.pr).toEqual({ | |
| 194 | 194 | number: 13, |
| 195 | 195 | title: "Existing PR", |
| 196 | 196 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", |
| 197 | 197 | baseBranch: "main", |
| 198 | 198 | headBranch: "feature/status-open-pr", |
| 199 | state: "open", | |
| 199 | 200 | }); |
| 200 | expect(status.mergedPr).toBeNull(); | |
| 201 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false); | |
| 201 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); | |
| 202 | 202 | }); |
| 203 | 203 | |
| 204 | 204 | it("status includes merged PR metadata when there is no open PR", async () => { |
| ... | ... | @@ -211,7 +211,6 @@ describe("GitManager", () => { |
| 211 | 211 | |
| 212 | 212 | const { runner, ghCalls } = createRunnerWithFakeGh({ |
| 213 | 213 | prListSequence: [ |
| 214 | "[]", | |
| 215 | 214 | JSON.stringify([ |
| 216 | 215 | { |
| 217 | 216 | number: 26, |
| ... | ... | @@ -219,6 +218,9 @@ describe("GitManager", () => { |
| 219 | 218 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", |
| 220 | 219 | baseRefName: "main", |
| 221 | 220 | headRefName: "feature/status-merged-pr", |
| 221 | state: "CLOSED", | |
| 222 | mergedAt: "2026-02-14T10:00:00Z", | |
| 223 | updatedAt: "2026-02-14T10:00:00Z", | |
| 222 | 224 | }, |
| 223 | 225 | ]), |
| 224 | 226 | ], |
| ... | ... | @@ -231,18 +233,18 @@ describe("GitManager", () => { |
| 231 | 233 | |
| 232 | 234 | const status = await manager.status({ cwd: repoDir }); |
| 233 | 235 | expect(status.branch).toBe("feature/status-merged-pr"); |
| 234 | expect(status.openPr).toBeNull(); | |
| 235 | expect(status.mergedPr).toEqual({ | |
| 236 | expect(status.pr).toEqual({ | |
| 236 | 237 | number: 26, |
| 237 | 238 | title: "Merged PR", |
| 238 | 239 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", |
| 239 | 240 | baseBranch: "main", |
| 240 | 241 | headBranch: "feature/status-merged-pr", |
| 242 | state: "merged", | |
| 241 | 243 | }); |
| 242 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); | |
| 244 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); | |
| 243 | 245 | }); |
| 244 | 246 | |
| 245 | it("status skips open PR lookup for branches without upstream", async () => { | |
| 247 | it("status checks all PR states for branches without upstream", async () => { | |
| 246 | 248 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 247 | 249 | await initRepo(repoDir); |
| 248 | 250 | await runGit(repoDir, ["checkout", "-b", "feature/local-only"]); |
| ... | ... | @@ -258,13 +260,11 @@ describe("GitManager", () => { |
| 258 | 260 | |
| 259 | 261 | const status = await manager.status({ cwd: repoDir }); |
| 260 | 262 | expect(status.branch).toBe("feature/local-only"); |
| 261 | expect(status.openPr).toBeNull(); | |
| 262 | expect(status.mergedPr).toBeNull(); | |
| 263 | expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false); | |
| 264 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); | |
| 263 | expect(status.pr).toBeNull(); | |
| 264 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); | |
| 265 | 265 | }); |
| 266 | 266 | |
| 267 | it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => { | |
| 267 | it("status is resilient to gh lookup failures and returns pr null", async () => { | |
| 268 | 268 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 269 | 269 | await initRepo(repoDir); |
| 270 | 270 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); |
| ... | ... | @@ -282,8 +282,7 @@ describe("GitManager", () => { |
| 282 | 282 | |
| 283 | 283 | const status = await manager.status({ cwd: repoDir }); |
| 284 | 284 | expect(status.branch).toBe("feature/status-no-gh"); |
| 285 | expect(status.openPr).toBeNull(); | |
| 286 | expect(status.mergedPr).toBeNull(); | |
| 285 | expect(status.pr).toBeNull(); | |
| 287 | 286 | }); |
| 288 | 287 | |
| 289 | 288 | it("creates a commit when working tree is dirty", async () => { |
apps/server/src/gitManager.ts+2-3| ... | ... | @@ -67,8 +67,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 67 | 67 | typeof title !== "string" || |
| 68 | 68 | typeof url !== "string" || |
| 69 | 69 | typeof baseRefName !== "string" || |
| 70 | typeof headRefName !== "string" || | |
| 71 | typeof state !== "string" | |
| 70 | typeof headRefName !== "string" | |
| 72 | 71 | ) { |
| 73 | 72 | continue; |
| 74 | 73 | } |
| ... | ... | @@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 76 | 75 | let normalizedState: GitStatusPrState; |
| 77 | 76 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { |
| 78 | 77 | normalizedState = "merged"; |
| 79 | } else if (state === "OPEN") { | |
| 78 | } else if (state === "OPEN" || state === undefined || state === null) { | |
| 80 | 79 | normalizedState = "open"; |
| 81 | 80 | } else if (state === "CLOSED") { |
| 82 | 81 | normalizedState = "closed"; |
apps/server/src/keybindings.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 2 | ||
| 3 | import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; | |
| 4 | ||
| 5 | describe("server keybindings", () => { | |
| 6 | it("parses shortcuts including plus key", () => { | |
| 7 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { | |
| 8 | key: "j", | |
| 9 | metaKey: false, | |
| 10 | ctrlKey: false, | |
| 11 | shiftKey: false, | |
| 12 | altKey: false, | |
| 13 | modKey: true, | |
| 14 | }); | |
| 15 | assert.deepEqual(parseKeybindingShortcut("mod++"), { | |
| 16 | key: "+", | |
| 17 | metaKey: false, | |
| 18 | ctrlKey: false, | |
| 19 | shiftKey: false, | |
| 20 | altKey: false, | |
| 21 | modKey: true, | |
| 22 | }); | |
| 23 | }); | |
| 24 | ||
| 25 | it("compiles valid rule with parsed when AST", () => { | |
| 26 | const compiled = compileResolvedKeybindingRule({ | |
| 27 | key: "mod+d", | |
| 28 | command: "terminal.split", | |
| 29 | when: "terminalOpen && !terminalFocus", | |
| 30 | }); | |
| 31 | ||
| 32 | assert.deepEqual(compiled, { | |
| 33 | command: "terminal.split", | |
| 34 | shortcut: { | |
| 35 | key: "d", | |
| 36 | metaKey: false, | |
| 37 | ctrlKey: false, | |
| 38 | shiftKey: false, | |
| 39 | altKey: false, | |
| 40 | modKey: true, | |
| 41 | }, | |
| 42 | whenAst: { | |
| 43 | type: "and", | |
| 44 | left: { type: "identifier", name: "terminalOpen" }, | |
| 45 | right: { | |
| 46 | type: "not", | |
| 47 | node: { type: "identifier", name: "terminalFocus" }, | |
| 48 | }, | |
| 49 | }, | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | it("rejects invalid rules", () => { | |
| 54 | assert.isNull( | |
| 55 | compileResolvedKeybindingRule({ | |
| 56 | key: "mod+shift+d+o", | |
| 57 | command: "terminal.new", | |
| 58 | }), | |
| 59 | ); | |
| 60 | ||
| 61 | assert.isNull( | |
| 62 | compileResolvedKeybindingRule({ | |
| 63 | key: "mod+d", | |
| 64 | command: "terminal.split", | |
| 65 | when: "terminalFocus && (", | |
| 66 | }), | |
| 67 | ); | |
| 68 | }); | |
| 69 | }); |
apps/server/src/keybindings.ts created+355| ... | ... | @@ -0,0 +1,355 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import type { | |
| 6 | KeybindingRule, | |
| 7 | KeybindingsConfig, | |
| 8 | KeybindingShortcut, | |
| 9 | KeybindingWhenNode, | |
| 10 | ResolvedKeybindingRule, | |
| 11 | ResolvedKeybindingsConfig, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { keybindingRuleSchema } from "@t3tools/contracts"; | |
| 14 | ||
| 15 | interface KeybindingsLogger { | |
| 16 | warn(message: string, context?: Record<string, unknown>): void; | |
| 17 | } | |
| 18 | ||
| 19 | type WhenToken = | |
| 20 | | { type: "identifier"; value: string } | |
| 21 | | { type: "not" } | |
| 22 | | { type: "and" } | |
| 23 | | { type: "or" } | |
| 24 | | { type: "lparen" } | |
| 25 | | { type: "rparen" }; | |
| 26 | ||
| 27 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | |
| 28 | { key: "mod+j", command: "terminal.toggle" }, | |
| 29 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 30 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 31 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | |
| 32 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | |
| 33 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | |
| 34 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | |
| 35 | { key: "mod+o", command: "editor.openFavorite" }, | |
| 36 | ]; | |
| 37 | ||
| 38 | function normalizeKeyToken(token: string): string { | |
| 39 | if (token === "space") return " "; | |
| 40 | if (token === "esc") return "escape"; | |
| 41 | return token; | |
| 42 | } | |
| 43 | ||
| 44 | export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { | |
| 45 | const rawTokens = value | |
| 46 | .toLowerCase() | |
| 47 | .split("+") | |
| 48 | .map((token) => token.trim()); | |
| 49 | const tokens = [...rawTokens]; | |
| 50 | let trailingEmptyCount = 0; | |
| 51 | while (tokens[tokens.length - 1] === "") { | |
| 52 | trailingEmptyCount += 1; | |
| 53 | tokens.pop(); | |
| 54 | } | |
| 55 | if (trailingEmptyCount > 0) { | |
| 56 | tokens.push("+"); | |
| 57 | } | |
| 58 | if (tokens.some((token) => token.length === 0)) { | |
| 59 | return null; | |
| 60 | } | |
| 61 | if (tokens.length === 0) return null; | |
| 62 | ||
| 63 | let key: string | null = null; | |
| 64 | let metaKey = false; | |
| 65 | let ctrlKey = false; | |
| 66 | let shiftKey = false; | |
| 67 | let altKey = false; | |
| 68 | let modKey = false; | |
| 69 | ||
| 70 | for (const token of tokens) { | |
| 71 | switch (token) { | |
| 72 | case "cmd": | |
| 73 | case "meta": | |
| 74 | metaKey = true; | |
| 75 | break; | |
| 76 | case "ctrl": | |
| 77 | case "control": | |
| 78 | ctrlKey = true; | |
| 79 | break; | |
| 80 | case "shift": | |
| 81 | shiftKey = true; | |
| 82 | break; | |
| 83 | case "alt": | |
| 84 | case "option": | |
| 85 | altKey = true; | |
| 86 | break; | |
| 87 | case "mod": | |
| 88 | modKey = true; | |
| 89 | break; | |
| 90 | default: { | |
| 91 | if (key !== null) return null; | |
| 92 | key = normalizeKeyToken(token); | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | if (key === null) return null; | |
| 98 | return { | |
| 99 | key, | |
| 100 | metaKey, | |
| 101 | ctrlKey, | |
| 102 | shiftKey, | |
| 103 | altKey, | |
| 104 | modKey, | |
| 105 | }; | |
| 106 | } | |
| 107 | ||
| 108 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | |
| 109 | const tokens: WhenToken[] = []; | |
| 110 | let index = 0; | |
| 111 | ||
| 112 | while (index < expression.length) { | |
| 113 | const current = expression[index]; | |
| 114 | if (!current) break; | |
| 115 | ||
| 116 | if (/\s/.test(current)) { | |
| 117 | index += 1; | |
| 118 | continue; | |
| 119 | } | |
| 120 | if (expression.startsWith("&&", index)) { | |
| 121 | tokens.push({ type: "and" }); | |
| 122 | index += 2; | |
| 123 | continue; | |
| 124 | } | |
| 125 | if (expression.startsWith("||", index)) { | |
| 126 | tokens.push({ type: "or" }); | |
| 127 | index += 2; | |
| 128 | continue; | |
| 129 | } | |
| 130 | if (current === "!") { | |
| 131 | tokens.push({ type: "not" }); | |
| 132 | index += 1; | |
| 133 | continue; | |
| 134 | } | |
| 135 | if (current === "(") { | |
| 136 | tokens.push({ type: "lparen" }); | |
| 137 | index += 1; | |
| 138 | continue; | |
| 139 | } | |
| 140 | if (current === ")") { | |
| 141 | tokens.push({ type: "rparen" }); | |
| 142 | index += 1; | |
| 143 | continue; | |
| 144 | } | |
| 145 | ||
| 146 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | |
| 147 | if (!identifier) { | |
| 148 | return null; | |
| 149 | } | |
| 150 | tokens.push({ type: "identifier", value: identifier[0] }); | |
| 151 | index += identifier[0].length; | |
| 152 | } | |
| 153 | ||
| 154 | return tokens; | |
| 155 | } | |
| 156 | ||
| 157 | export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | |
| 158 | const tokens = tokenizeWhenExpression(expression); | |
| 159 | if (!tokens || tokens.length === 0) return null; | |
| 160 | let index = 0; | |
| 161 | ||
| 162 | const parsePrimary = (): KeybindingWhenNode | null => { | |
| 163 | const token = tokens[index]; | |
| 164 | if (!token) return null; | |
| 165 | ||
| 166 | if (token.type === "identifier") { | |
| 167 | index += 1; | |
| 168 | return { type: "identifier", name: token.value }; | |
| 169 | } | |
| 170 | ||
| 171 | if (token.type === "lparen") { | |
| 172 | index += 1; | |
| 173 | const expressionNode = parseOr(); | |
| 174 | const closeToken = tokens[index]; | |
| 175 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | |
| 176 | return null; | |
| 177 | } | |
| 178 | index += 1; | |
| 179 | return expressionNode; | |
| 180 | } | |
| 181 | ||
| 182 | return null; | |
| 183 | }; | |
| 184 | ||
| 185 | const parseUnary = (): KeybindingWhenNode | null => { | |
| 186 | const token = tokens[index]; | |
| 187 | if (token?.type === "not") { | |
| 188 | index += 1; | |
| 189 | const node = parseUnary(); | |
| 190 | if (!node) return null; | |
| 191 | return { type: "not", node }; | |
| 192 | } | |
| 193 | return parsePrimary(); | |
| 194 | }; | |
| 195 | ||
| 196 | const parseAnd = (): KeybindingWhenNode | null => { | |
| 197 | let left = parseUnary(); | |
| 198 | if (!left) return null; | |
| 199 | ||
| 200 | while (tokens[index]?.type === "and") { | |
| 201 | index += 1; | |
| 202 | const right = parseUnary(); | |
| 203 | if (!right) return null; | |
| 204 | left = { type: "and", left, right }; | |
| 205 | } | |
| 206 | ||
| 207 | return left; | |
| 208 | }; | |
| 209 | ||
| 210 | const parseOr = (): KeybindingWhenNode | null => { | |
| 211 | let left = parseAnd(); | |
| 212 | if (!left) return null; | |
| 213 | ||
| 214 | while (tokens[index]?.type === "or") { | |
| 215 | index += 1; | |
| 216 | const right = parseAnd(); | |
| 217 | if (!right) return null; | |
| 218 | left = { type: "or", left, right }; | |
| 219 | } | |
| 220 | ||
| 221 | return left; | |
| 222 | }; | |
| 223 | ||
| 224 | const ast = parseOr(); | |
| 225 | if (!ast || index !== tokens.length) return null; | |
| 226 | return ast; | |
| 227 | } | |
| 228 | ||
| 229 | export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { | |
| 230 | const key = rule.key.trim(); | |
| 231 | if (key.length === 0) return null; | |
| 232 | const shortcut = parseKeybindingShortcut(key); | |
| 233 | if (!shortcut) return null; | |
| 234 | ||
| 235 | const when = rule.when?.trim(); | |
| 236 | if (when && when.length > 0) { | |
| 237 | const whenAst = parseKeybindingWhenExpression(when); | |
| 238 | if (!whenAst) return null; | |
| 239 | return { | |
| 240 | command: rule.command, | |
| 241 | shortcut, | |
| 242 | whenAst, | |
| 243 | }; | |
| 244 | } | |
| 245 | ||
| 246 | return { | |
| 247 | command: rule.command, | |
| 248 | shortcut, | |
| 249 | }; | |
| 250 | } | |
| 251 | ||
| 252 | export function compileResolvedKeybindingsConfig( | |
| 253 | config: KeybindingsConfig, | |
| 254 | ): ResolvedKeybindingsConfig { | |
| 255 | const compiled: ResolvedKeybindingsConfig = []; | |
| 256 | for (const rule of config) { | |
| 257 | const resolved = compileResolvedKeybindingRule(rule); | |
| 258 | if (!resolved) continue; | |
| 259 | compiled.push(resolved); | |
| 260 | } | |
| 261 | return compiled; | |
| 262 | } | |
| 263 | ||
| 264 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | |
| 265 | const resolved: ResolvedKeybindingsConfig = []; | |
| 266 | for (const rule of DEFAULT_KEYBINDINGS) { | |
| 267 | const compiled = compileResolvedKeybindingRule(rule); | |
| 268 | if (!compiled) { | |
| 269 | throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); | |
| 270 | } | |
| 271 | resolved.push(compiled); | |
| 272 | } | |
| 273 | return resolved; | |
| 274 | } | |
| 275 | ||
| 276 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); | |
| 277 | ||
| 278 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | |
| 279 | if (custom.length === 0) { | |
| 280 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 281 | } | |
| 282 | ||
| 283 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 284 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 285 | (binding) => !overriddenCommands.has(binding.command), | |
| 286 | ); | |
| 287 | const merged = [...retainedDefaults, ...custom]; | |
| 288 | ||
| 289 | if (merged.length <= 256) { | |
| 290 | return merged; | |
| 291 | } | |
| 292 | ||
| 293 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 294 | return merged.slice(-256); | |
| 295 | } | |
| 296 | ||
| 297 | export function loadResolvedKeybindingsConfig( | |
| 298 | logger: KeybindingsLogger, | |
| 299 | ): ResolvedKeybindingsConfig { | |
| 300 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | |
| 301 | try { | |
| 302 | const raw = fs.readFileSync(configPath, "utf8"); | |
| 303 | const parsed = JSON.parse(raw) as unknown; | |
| 304 | if (!Array.isArray(parsed)) { | |
| 305 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | |
| 306 | path: configPath, | |
| 307 | }); | |
| 308 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 309 | } | |
| 310 | ||
| 311 | const sanitized: ResolvedKeybindingsConfig = []; | |
| 312 | let invalidEntries = 0; | |
| 313 | for (const entry of parsed) { | |
| 314 | const result = keybindingRuleSchema.safeParse(entry); | |
| 315 | if (result.success) { | |
| 316 | const compiled = compileResolvedKeybindingRule(result.data); | |
| 317 | if (!compiled) { | |
| 318 | invalidEntries += 1; | |
| 319 | continue; | |
| 320 | } | |
| 321 | sanitized.push(compiled); | |
| 322 | continue; | |
| 323 | } | |
| 324 | invalidEntries += 1; | |
| 325 | } | |
| 326 | if (invalidEntries > 0) { | |
| 327 | logger.warn("ignoring invalid keybinding entries", { | |
| 328 | path: configPath, | |
| 329 | invalidEntries, | |
| 330 | totalEntries: parsed.length, | |
| 331 | }); | |
| 332 | } | |
| 333 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | |
| 334 | const mergedBeforeCapLength = | |
| 335 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 336 | .length + sanitized.length; | |
| 337 | const merged = mergeWithDefaultKeybindings(sanitized); | |
| 338 | if (mergedBeforeCapLength > 256) { | |
| 339 | logger.warn("truncating merged keybindings config to max entries", { | |
| 340 | path: configPath, | |
| 341 | maxEntries: 256, | |
| 342 | }); | |
| 343 | } | |
| 344 | return merged; | |
| 345 | } catch (error) { | |
| 346 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | |
| 347 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 348 | } | |
| 349 | logger.warn("ignoring malformed keybindings config", { | |
| 350 | path: configPath, | |
| 351 | error: error instanceof Error ? error.message : String(error), | |
| 352 | }); | |
| 353 | } | |
| 354 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 355 | } |
apps/server/src/wsServer.test.ts+248-1| ... | ... | @@ -11,10 +11,13 @@ import { |
| 11 | 11 | DEFAULT_TERMINAL_ID, |
| 12 | 12 | WS_CHANNELS, |
| 13 | 13 | WS_METHODS, |
| 14 | type KeybindingsConfig, | |
| 15 | type ResolvedKeybindingsConfig, | |
| 14 | 16 | type WsPush, |
| 15 | 17 | type WsResponse, |
| 16 | 18 | } from "@t3tools/contracts"; |
| 17 | 19 | import { ProjectRegistry } from "./projectRegistry"; |
| 20 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; | |
| 18 | 21 | import type { |
| 19 | 22 | TerminalClearInput, |
| 20 | 23 | TerminalCloseInput, |
| ... | ... | @@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro |
| 192 | 195 | } |
| 193 | 196 | } |
| 194 | 197 | |
| 198 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | |
| 199 | const resolved: ResolvedKeybindingsConfig = []; | |
| 200 | for (const binding of bindings) { | |
| 201 | const compiled = compileResolvedKeybindingRule(binding); | |
| 202 | if (!compiled) { | |
| 203 | throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); | |
| 204 | } | |
| 205 | resolved.push(compiled); | |
| 206 | } | |
| 207 | return resolved; | |
| 208 | } | |
| 209 | ||
| 210 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); | |
| 211 | ||
| 212 | function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { | |
| 213 | if (custom.length === 0) { | |
| 214 | return DEFAULT_RESOLVED_KEYBINDINGS; | |
| 215 | } | |
| 216 | ||
| 217 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 218 | const retainedDefaults = DEFAULT_KEYBINDINGS.filter( | |
| 219 | (binding) => !overriddenCommands.has(binding.command), | |
| 220 | ); | |
| 221 | return compileKeybindings([...retainedDefaults, ...custom].slice(-256)); | |
| 222 | } | |
| 223 | ||
| 195 | 224 | describe("WebSocket Server", () => { |
| 196 | 225 | let server: ReturnType<typeof createServer> | null = null; |
| 197 | 226 | const connections: WebSocket[] = []; |
| ... | ... | @@ -307,7 +336,225 @@ describe("WebSocket Server", () => { |
| 307 | 336 | |
| 308 | 337 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 309 | 338 | expect(response.error).toBeUndefined(); |
| 310 | expect(response.result).toEqual({ cwd: "/my/workspace" }); | |
| 339 | expect(response.result).toEqual({ | |
| 340 | cwd: "/my/workspace", | |
| 341 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 342 | }); | |
| 343 | }); | |
| 344 | ||
| 345 | it("reads keybindings from ~/.t3/keybindings.json", async () => { | |
| 346 | const fakeHome = makeTempDir("t3code-home-"); | |
| 347 | const configDir = path.join(fakeHome, ".t3"); | |
| 348 | fs.mkdirSync(configDir, { recursive: true }); | |
| 349 | fs.writeFileSync( | |
| 350 | path.join(configDir, "keybindings.json"), | |
| 351 | JSON.stringify([ | |
| 352 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 353 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 354 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 355 | ]), | |
| 356 | "utf8", | |
| 357 | ); | |
| 358 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 359 | ||
| 360 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 361 | await server.start(); | |
| 362 | const addr = server.httpServer.address(); | |
| 363 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 364 | ||
| 365 | const ws = await connectWs(port); | |
| 366 | connections.push(ws); | |
| 367 | ||
| 368 | await waitForMessage(ws); | |
| 369 | ||
| 370 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 371 | expect(response.error).toBeUndefined(); | |
| 372 | expect(response.result).toEqual({ | |
| 373 | cwd: "/my/workspace", | |
| 374 | keybindings: mergeWithDefaultsForTest([ | |
| 375 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 376 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 377 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 378 | ]), | |
| 379 | }); | |
| 380 | }); | |
| 381 | ||
| 382 | it("warns and ignores invalid keybinding entries", async () => { | |
| 383 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 384 | const fakeHome = makeTempDir("t3code-home-invalid-entry-"); | |
| 385 | const configDir = path.join(fakeHome, ".t3"); | |
| 386 | fs.mkdirSync(configDir, { recursive: true }); | |
| 387 | fs.writeFileSync( | |
| 388 | path.join(configDir, "keybindings.json"), | |
| 389 | JSON.stringify([ | |
| 390 | { key: "mod+j", command: "terminal.toggle" }, | |
| 391 | { key: "mod+z", command: "invalid.command", when: "terminalFocus" }, | |
| 392 | ]), | |
| 393 | "utf8", | |
| 394 | ); | |
| 395 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 396 | ||
| 397 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 398 | await server.start(); | |
| 399 | const addr = server.httpServer.address(); | |
| 400 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 401 | ||
| 402 | const ws = await connectWs(port); | |
| 403 | connections.push(ws); | |
| 404 | ||
| 405 | await waitForMessage(ws); | |
| 406 | ||
| 407 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 408 | expect(response.error).toBeUndefined(); | |
| 409 | expect(response.result).toEqual({ | |
| 410 | cwd: "/my/workspace", | |
| 411 | keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 412 | }); | |
| 413 | expect( | |
| 414 | warnSpy.mock.calls.some(([message]) => | |
| 415 | String(message).includes("ignoring invalid keybinding entries"), | |
| 416 | ), | |
| 417 | ).toBe(true); | |
| 418 | }); | |
| 419 | ||
| 420 | it("warns and ignores keybindings with malformed when expressions", async () => { | |
| 421 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 422 | const fakeHome = makeTempDir("t3code-home-invalid-when-"); | |
| 423 | const configDir = path.join(fakeHome, ".t3"); | |
| 424 | fs.mkdirSync(configDir, { recursive: true }); | |
| 425 | fs.writeFileSync( | |
| 426 | path.join(configDir, "keybindings.json"), | |
| 427 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle", when: "terminalFocus && (" }]), | |
| 428 | "utf8", | |
| 429 | ); | |
| 430 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 431 | ||
| 432 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 433 | await server.start(); | |
| 434 | const addr = server.httpServer.address(); | |
| 435 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 436 | ||
| 437 | const ws = await connectWs(port); | |
| 438 | connections.push(ws); | |
| 439 | ||
| 440 | await waitForMessage(ws); | |
| 441 | ||
| 442 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 443 | expect(response.error).toBeUndefined(); | |
| 444 | expect(response.result).toEqual({ | |
| 445 | cwd: "/my/workspace", | |
| 446 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 447 | }); | |
| 448 | expect( | |
| 449 | warnSpy.mock.calls.some(([message]) => | |
| 450 | String(message).includes("ignoring invalid keybinding entries"), | |
| 451 | ), | |
| 452 | ).toBe(true); | |
| 453 | }); | |
| 454 | ||
| 455 | it("reads keybindings once at startup and caches the resolved config", async () => { | |
| 456 | const fakeHome = makeTempDir("t3code-home-cached-config-"); | |
| 457 | const configDir = path.join(fakeHome, ".t3"); | |
| 458 | fs.mkdirSync(configDir, { recursive: true }); | |
| 459 | const keybindingsPath = path.join(configDir, "keybindings.json"); | |
| 460 | fs.writeFileSync( | |
| 461 | keybindingsPath, | |
| 462 | JSON.stringify([{ key: "cmd+j", command: "terminal.toggle" }]), | |
| 463 | "utf8", | |
| 464 | ); | |
| 465 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 466 | ||
| 467 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 468 | await server.start(); | |
| 469 | const addr = server.httpServer.address(); | |
| 470 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 471 | ||
| 472 | const ws = await connectWs(port); | |
| 473 | connections.push(ws); | |
| 474 | await waitForMessage(ws); | |
| 475 | ||
| 476 | const firstResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 477 | expect(firstResponse.error).toBeUndefined(); | |
| 478 | expect(firstResponse.result).toEqual({ | |
| 479 | cwd: "/my/workspace", | |
| 480 | keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]), | |
| 481 | }); | |
| 482 | ||
| 483 | fs.writeFileSync( | |
| 484 | keybindingsPath, | |
| 485 | JSON.stringify([{ key: "cmd+k", command: "terminal.toggle" }]), | |
| 486 | "utf8", | |
| 487 | ); | |
| 488 | ||
| 489 | const secondResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 490 | expect(secondResponse.error).toBeUndefined(); | |
| 491 | expect(secondResponse.result).toEqual(firstResponse.result); | |
| 492 | }); | |
| 493 | ||
| 494 | it("warns and ignores unsupported keybindings config format", async () => { | |
| 495 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 496 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); | |
| 497 | const configDir = path.join(fakeHome, ".t3"); | |
| 498 | fs.mkdirSync(configDir, { recursive: true }); | |
| 499 | fs.writeFileSync( | |
| 500 | path.join(configDir, "keybindings.json"), | |
| 501 | JSON.stringify({ "terminal.toggle": "mod+j" }), | |
| 502 | "utf8", | |
| 503 | ); | |
| 504 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 505 | ||
| 506 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 507 | await server.start(); | |
| 508 | const addr = server.httpServer.address(); | |
| 509 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 510 | ||
| 511 | const ws = await connectWs(port); | |
| 512 | connections.push(ws); | |
| 513 | ||
| 514 | await waitForMessage(ws); | |
| 515 | ||
| 516 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 517 | expect(response.error).toBeUndefined(); | |
| 518 | expect(response.result).toEqual({ | |
| 519 | cwd: "/my/workspace", | |
| 520 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 521 | }); | |
| 522 | expect( | |
| 523 | warnSpy.mock.calls.some(([message]) => | |
| 524 | String(message).includes("unsupported format; expected array"), | |
| 525 | ), | |
| 526 | ).toBe(true); | |
| 527 | }); | |
| 528 | ||
| 529 | it("warns and ignores malformed keybindings config files", async () => { | |
| 530 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 531 | const fakeHome = makeTempDir("t3code-home-invalid-"); | |
| 532 | const configDir = path.join(fakeHome, ".t3"); | |
| 533 | fs.mkdirSync(configDir, { recursive: true }); | |
| 534 | fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); | |
| 535 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 536 | ||
| 537 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 538 | await server.start(); | |
| 539 | const addr = server.httpServer.address(); | |
| 540 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 541 | ||
| 542 | const ws = await connectWs(port); | |
| 543 | connections.push(ws); | |
| 544 | ||
| 545 | await waitForMessage(ws); | |
| 546 | ||
| 547 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 548 | expect(response.error).toBeUndefined(); | |
| 549 | expect(response.result).toEqual({ | |
| 550 | cwd: "/my/workspace", | |
| 551 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 552 | }); | |
| 553 | expect( | |
| 554 | warnSpy.mock.calls.some(([message]) => | |
| 555 | String(message).includes("ignoring malformed keybindings config"), | |
| 556 | ), | |
| 557 | ).toBe(true); | |
| 311 | 558 | }); |
| 312 | 559 | |
| 313 | 560 | it("returns error for unknown methods", async () => { |
apps/server/src/wsServer.ts+6-1| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | removeGitWorktree, |
| 32 | 32 | } from "./git"; |
| 33 | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | |
| 34 | 35 | |
| 35 | 36 | const MIME_TYPES: Record<string, string> = { |
| 36 | 37 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) { |
| 90 | 91 | const logger = createLogger("ws"); |
| 91 | 92 | const logWebSocketEvents = |
| 92 | 93 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); |
| 94 | const keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 93 | 95 | |
| 94 | 96 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 95 | 97 | if (!logWebSocketEvents) return; |
| ... | ... | @@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) { |
| 410 | 412 | return undefined; |
| 411 | 413 | |
| 412 | 414 | case WS_METHODS.serverGetConfig: |
| 413 | return { cwd }; | |
| 415 | return { | |
| 416 | cwd, | |
| 417 | keybindings: keybindingsConfig, | |
| 418 | }; | |
| 414 | 419 | |
| 415 | 420 | default: |
| 416 | 421 | throw new Error(`Unknown method: ${request.method}`); |
apps/web/src/components/BranchToolbar.tsx+11-1| ... | ... | @@ -25,9 +25,15 @@ interface BranchToolbarProps { |
| 25 | 25 | envMode: "local" | "worktree"; |
| 26 | 26 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 27 | 27 | envLocked: boolean; |
| 28 | onComposerFocusRequest?: () => void; | |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { | |
| 31 | export default function BranchToolbar({ | |
| 32 | envMode, | |
| 33 | onEnvModeChange, | |
| 34 | envLocked, | |
| 35 | onComposerFocusRequest, | |
| 36 | }: BranchToolbarProps) { | |
| 31 | 37 | const { state, dispatch } = useStore(); |
| 32 | 38 | const api = useNativeApi(); |
| 33 | 39 | const queryClient = useQueryClient(); |
| ... | ... | @@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 123 | 129 | setThreadError(null); |
| 124 | 130 | setThreadBranch(branch.name, null); |
| 125 | 131 | setIsBranchMenuOpen(false); |
| 132 | onComposerFocusRequest?.(); | |
| 126 | 133 | return; |
| 127 | 134 | } |
| 128 | 135 | |
| ... | ... | @@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 135 | 142 | // Secondary worktree → point the thread at that worktree path. |
| 136 | 143 | setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); |
| 137 | 144 | setIsBranchMenuOpen(false); |
| 145 | onComposerFocusRequest?.(); | |
| 138 | 146 | return; |
| 139 | 147 | } |
| 140 | 148 | |
| ... | ... | @@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 143 | 151 | setThreadError(null); |
| 144 | 152 | setThreadBranch(branch.name, activeWorktreePath); |
| 145 | 153 | setIsBranchMenuOpen(false); |
| 154 | onComposerFocusRequest?.(); | |
| 146 | 155 | }, |
| 147 | 156 | }); |
| 148 | 157 | }; |
| ... | ... | @@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 156 | 165 | setThreadBranch(name, activeWorktreePath); |
| 157 | 166 | setBranchQuery(""); |
| 158 | 167 | setIsBranchMenuOpen(false); |
| 168 | onComposerFocusRequest?.(); | |
| 159 | 169 | }, |
| 160 | 170 | }); |
| 161 | 171 | }; |
apps/web/src/components/ChatMarkdown.tsx+4-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { memo } from "react"; | |
| 1 | 2 | import type { Components } from "react-markdown"; |
| 2 | 3 | import ReactMarkdown from "react-markdown"; |
| 3 | 4 | import rehypeHighlight from "rehype-highlight"; |
| ... | ... | @@ -13,7 +14,7 @@ const markdownComponents: Components = { |
| 13 | 14 | }, |
| 14 | 15 | }; |
| 15 | 16 | |
| 16 | export default function ChatMarkdown({ text }: ChatMarkdownProps) { | |
| 17 | function ChatMarkdown({ text }: ChatMarkdownProps) { | |
| 17 | 18 | return ( |
| 18 | 19 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> |
| 19 | 20 | <ReactMarkdown |
| ... | ... | @@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) { |
| 26 | 27 | </div> |
| 27 | 28 | ); |
| 28 | 29 | } |
| 30 | ||
| 31 | export default memo(ChatMarkdown); |
apps/web/src/components/ChatView.tsx+585-349| ... | ... | @@ -1,10 +1,14 @@ |
| 1 | 1 | import { |
| 2 | EDITORS, | |
| 3 | type EditorId, | |
| 4 | ModelSlug, | |
| 5 | type NativeApi, | |
| 2 | 6 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 4 | 8 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | type ProviderSendTurnAttachmentInput, | |
| 9 | type ResolvedKeybindingsConfig, | |
| 6 | 10 | type ProviderApprovalDecision, |
| 7 | ModelSlug, | |
| 11 | type ProviderSendTurnAttachmentInput, | |
| 8 | 12 | } from "@t3tools/contracts"; |
| 9 | 13 | import { |
| 10 | 14 | type ClipboardEvent, |
| ... | ... | @@ -12,6 +16,8 @@ import { |
| 12 | 16 | type FormEvent, |
| 13 | 17 | Fragment, |
| 14 | 18 | type KeyboardEvent, |
| 19 | memo, | |
| 20 | type RefObject, | |
| 15 | 21 | useCallback, |
| 16 | 22 | useEffect, |
| 17 | 23 | useLayoutEffect, |
| ... | ... | @@ -20,12 +26,9 @@ import { |
| 20 | 26 | useState, |
| 21 | 27 | } from "react"; |
| 22 | 28 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 23 | import { | |
| 24 | gitBranchesQueryOptions, | |
| 25 | gitCreateWorktreeMutationOptions, | |
| 26 | } from "~/lib/gitReactQuery"; | |
| 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | |
| 30 | import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | |
| 27 | 31 | |
| 28 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | |
| 29 | 32 | import { isElectron } from "../env"; |
| 30 | 33 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 31 | 34 | import { |
| ... | ... | @@ -40,6 +43,7 @@ import { |
| 40 | 43 | derivePendingApprovals, |
| 41 | 44 | derivePhase, |
| 42 | 45 | deriveTimelineEntries, |
| 46 | type PendingApproval, | |
| 43 | 47 | deriveWorkLogEntries, |
| 44 | 48 | formatDuration, |
| 45 | 49 | formatElapsed, |
| ... | ... | @@ -50,7 +54,14 @@ import { useStore } from "../store"; |
| 50 | 54 | import type { ChatImageAttachment } from "../types"; |
| 51 | 55 | import BranchToolbar from "./BranchToolbar"; |
| 52 | 56 | import GitActionsControl from "./GitActionsControl"; |
| 53 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; | |
| 57 | import { | |
| 58 | isOpenFavoriteEditorShortcut, | |
| 59 | isTerminalCloseShortcut, | |
| 60 | isTerminalNewShortcut, | |
| 61 | isTerminalSplitShortcut, | |
| 62 | isTerminalToggleShortcut, | |
| 63 | shortcutLabelForCommand, | |
| 64 | } from "../keybindings"; | |
| 54 | 65 | import ChatMarkdown from "./ChatMarkdown"; |
| 55 | 66 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 56 | 67 | import { useNativeApi } from "../hooks/useNativeApi"; |
| ... | ... | @@ -70,7 +81,7 @@ import { Separator } from "./ui/separator"; |
| 70 | 81 | import { Group, GroupSeparator } from "./ui/group"; |
| 71 | 82 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; |
| 72 | 83 | import { CursorIcon, Icon } from "./Icons"; |
| 73 | import { cn } from "~/lib/utils"; | |
| 84 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | |
| 74 | 85 | import { Badge } from "./ui/badge"; |
| 75 | 86 | |
| 76 | 87 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | ... | @@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 83 | 94 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 84 | 95 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 85 | 96 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 97 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | |
| 86 | 98 | |
| 87 | 99 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 88 | 100 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | ... | @@ -130,7 +142,9 @@ export default function ChatView() { |
| 130 | 142 | const { state, dispatch } = useStore(); |
| 131 | 143 | const api = useNativeApi(); |
| 132 | 144 | const queryClient = useQueryClient(); |
| 133 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ api, queryClient })); | |
| 145 | const createWorktreeMutation = useMutation( | |
| 146 | gitCreateWorktreeMutationOptions({ api, queryClient }), | |
| 147 | ); | |
| 134 | 148 | const [prompt, setPrompt] = useState(""); |
| 135 | 149 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 136 | 150 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| ... | ... | @@ -154,6 +168,7 @@ export default function ChatView() { |
| 154 | 168 | |
| 155 | 169 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 156 | 170 | const activeThreadId = activeThread?.id ?? null; |
| 171 | const activeSessionId = activeThread?.session?.sessionId; | |
| 157 | 172 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 158 | 173 | const selectedModel = resolveModelSlug( |
| 159 | 174 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| ... | ... | @@ -254,14 +269,32 @@ export default function ChatView() { |
| 254 | 269 | } as const); |
| 255 | 270 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 256 | 271 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 272 | const keybindingsQuery = useQuery({ | |
| 273 | ...serverConfigQueryOptions(api), | |
| 274 | select: (config) => config.keybindings, | |
| 275 | }); | |
| 276 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | |
| 257 | 277 | // Default true while loading to avoid toolbar flicker. |
| 258 | 278 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 279 | const splitTerminalShortcutLabel = useMemo( | |
| 280 | () => shortcutLabelForCommand(keybindings, "terminal.split"), | |
| 281 | [keybindings], | |
| 282 | ); | |
| 283 | const newTerminalShortcutLabel = useMemo( | |
| 284 | () => shortcutLabelForCommand(keybindings, "terminal.new"), | |
| 285 | [keybindings], | |
| 286 | ); | |
| 287 | const closeTerminalShortcutLabel = useMemo( | |
| 288 | () => shortcutLabelForCommand(keybindings, "terminal.close"), | |
| 289 | [keybindings], | |
| 290 | ); | |
| 259 | 291 | |
| 260 | 292 | const envLocked = Boolean( |
| 261 | 293 | activeThread && |
| 262 | 294 | (activeThread.messages.length > 0 || |
| 263 | 295 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 264 | 296 | ); |
| 297 | ||
| 265 | 298 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 266 | 299 | for (const image of images) { |
| 267 | 300 | if (!image.previewUrl) continue; |
| ... | ... | @@ -275,6 +308,11 @@ export default function ChatView() { |
| 275 | 308 | const cursor = textarea.value.length; |
| 276 | 309 | textarea.setSelectionRange(cursor, cursor); |
| 277 | 310 | }, []); |
| 311 | const scheduleComposerFocus = useCallback(() => { | |
| 312 | window.requestAnimationFrame(() => { | |
| 313 | focusComposer(); | |
| 314 | }); | |
| 315 | }, [focusComposer]); | |
| 278 | 316 | const toggleTerminalVisibility = useCallback(() => { |
| 279 | 317 | if (!activeThreadId) return; |
| 280 | 318 | dispatch({ |
| ... | ... | @@ -346,6 +384,7 @@ export default function ChatView() { |
| 346 | 384 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 347 | 385 | if (mode === state.runtimeMode) return; |
| 348 | 386 | dispatch({ type: "SET_RUNTIME_MODE", mode }); |
| 387 | scheduleComposerFocus(); | |
| 349 | 388 | if (!api) return; |
| 350 | 389 | |
| 351 | 390 | const sessionIds = state.threads |
| ... | ... | @@ -463,7 +502,7 @@ export default function ChatView() { |
| 463 | 502 | if (phase !== "running") return; |
| 464 | 503 | const timer = window.setInterval(() => { |
| 465 | 504 | setNowTick(Date.now()); |
| 466 | }, 250); | |
| 505 | }, 1000); | |
| 467 | 506 | return () => { |
| 468 | 507 | window.clearInterval(timer); |
| 469 | 508 | }; |
| ... | ... | @@ -490,15 +529,74 @@ export default function ChatView() { |
| 490 | 529 | }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); |
| 491 | 530 | |
| 492 | 531 | useEffect(() => { |
| 532 | const isTerminalFocused = (): boolean => { | |
| 533 | const activeElement = document.activeElement; | |
| 534 | if (!(activeElement instanceof HTMLElement)) return false; | |
| 535 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | |
| 536 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | |
| 537 | }; | |
| 538 | ||
| 493 | 539 | const handler = (event: globalThis.KeyboardEvent) => { |
| 494 | if (!activeThreadId) return; | |
| 495 | if (!isTerminalToggleShortcut(event)) return; | |
| 540 | if (!activeThreadId || event.defaultPrevented) return; | |
| 541 | const shortcutContext = { | |
| 542 | terminalFocus: isTerminalFocused(), | |
| 543 | terminalOpen: Boolean(activeThread?.terminalOpen), | |
| 544 | }; | |
| 545 | ||
| 546 | if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { | |
| 547 | event.preventDefault(); | |
| 548 | event.stopPropagation(); | |
| 549 | toggleTerminalVisibility(); | |
| 550 | return; | |
| 551 | } | |
| 552 | ||
| 553 | if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { | |
| 554 | event.preventDefault(); | |
| 555 | event.stopPropagation(); | |
| 556 | if (!activeThread?.terminalOpen) { | |
| 557 | dispatch({ | |
| 558 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 559 | threadId: activeThreadId, | |
| 560 | open: true, | |
| 561 | }); | |
| 562 | } | |
| 563 | splitTerminal(); | |
| 564 | return; | |
| 565 | } | |
| 566 | ||
| 567 | if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { | |
| 568 | event.preventDefault(); | |
| 569 | event.stopPropagation(); | |
| 570 | if (!activeThread?.terminalOpen) return; | |
| 571 | closeTerminal(activeThread.activeTerminalId); | |
| 572 | return; | |
| 573 | } | |
| 574 | ||
| 575 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; | |
| 496 | 576 | event.preventDefault(); |
| 497 | toggleTerminalVisibility(); | |
| 577 | event.stopPropagation(); | |
| 578 | if (!activeThread?.terminalOpen) { | |
| 579 | dispatch({ | |
| 580 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 581 | threadId: activeThreadId, | |
| 582 | open: true, | |
| 583 | }); | |
| 584 | } | |
| 585 | createNewTerminal(); | |
| 498 | 586 | }; |
| 499 | 587 | window.addEventListener("keydown", handler); |
| 500 | 588 | return () => window.removeEventListener("keydown", handler); |
| 501 | }, [activeThreadId, toggleTerminalVisibility]); | |
| 589 | }, [ | |
| 590 | activeThread?.terminalOpen, | |
| 591 | activeThread?.activeTerminalId, | |
| 592 | activeThreadId, | |
| 593 | closeTerminal, | |
| 594 | createNewTerminal, | |
| 595 | dispatch, | |
| 596 | splitTerminal, | |
| 597 | keybindings, | |
| 598 | toggleTerminalVisibility, | |
| 599 | ]); | |
| 502 | 600 | |
| 503 | 601 | const setThreadError = (threadId: string | null, error: string | null) => { |
| 504 | 602 | if (!threadId) return; |
| ... | ... | @@ -796,37 +894,58 @@ export default function ChatView() { |
| 796 | 894 | }); |
| 797 | 895 | }; |
| 798 | 896 | |
| 799 | const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { | |
| 800 | if (!api || !activeThread?.session) return; | |
| 897 | const onRespondToApproval = useCallback( | |
| 898 | async (requestId: string, decision: ProviderApprovalDecision) => { | |
| 899 | if (!api || !activeSessionId || !activeThreadId) return; | |
| 801 | 900 | |
| 802 | setRespondingRequestIds((existing) => | |
| 803 | existing.includes(requestId) ? existing : [...existing, requestId], | |
| 804 | ); | |
| 805 | try { | |
| 806 | await api.providers.respondToRequest({ | |
| 807 | sessionId: activeThread.session.sessionId, | |
| 808 | requestId, | |
| 809 | decision, | |
| 810 | }); | |
| 811 | } catch (err) { | |
| 901 | setRespondingRequestIds((existing) => | |
| 902 | existing.includes(requestId) ? existing : [...existing, requestId], | |
| 903 | ); | |
| 904 | try { | |
| 905 | await api.providers.respondToRequest({ | |
| 906 | sessionId: activeSessionId, | |
| 907 | requestId, | |
| 908 | decision, | |
| 909 | }); | |
| 910 | } catch (err) { | |
| 911 | dispatch({ | |
| 912 | type: "SET_ERROR", | |
| 913 | threadId: activeThreadId, | |
| 914 | error: err instanceof Error ? err.message : "Failed to submit approval decision.", | |
| 915 | }); | |
| 916 | } finally { | |
| 917 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | |
| 918 | } | |
| 919 | }, | |
| 920 | [activeSessionId, activeThreadId, api, dispatch], | |
| 921 | ); | |
| 922 | ||
| 923 | const onModelSelect = useCallback( | |
| 924 | (model: ModelSlug) => { | |
| 925 | if (!activeThread) return; | |
| 812 | 926 | dispatch({ |
| 813 | type: "SET_ERROR", | |
| 927 | type: "SET_THREAD_MODEL", | |
| 814 | 928 | threadId: activeThread.id, |
| 815 | error: err instanceof Error ? err.message : "Failed to submit approval decision.", | |
| 929 | model: resolveModelSlug(model), | |
| 816 | 930 | }); |
| 817 | } finally { | |
| 818 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | |
| 819 | } | |
| 820 | }; | |
| 821 | ||
| 822 | const onModelSelect = (model: ModelSlug) => { | |
| 823 | if (!activeThread) return; | |
| 824 | dispatch({ | |
| 825 | type: "SET_THREAD_MODEL", | |
| 826 | threadId: activeThread.id, | |
| 827 | model: resolveModelSlug(model), | |
| 828 | }); | |
| 829 | }; | |
| 931 | scheduleComposerFocus(); | |
| 932 | }, | |
| 933 | [activeThread, dispatch, scheduleComposerFocus], | |
| 934 | ); | |
| 935 | const onEffortSelect = useCallback( | |
| 936 | (effort: ReasoningEffort) => { | |
| 937 | setSelectedEffort(effort); | |
| 938 | scheduleComposerFocus(); | |
| 939 | }, | |
| 940 | [scheduleComposerFocus], | |
| 941 | ); | |
| 942 | const onEnvModeChange = useCallback( | |
| 943 | (mode: "local" | "worktree") => { | |
| 944 | setEnvMode(mode); | |
| 945 | scheduleComposerFocus(); | |
| 946 | }, | |
| 947 | [scheduleComposerFocus], | |
| 948 | ); | |
| 830 | 949 | |
| 831 | 950 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { |
| 832 | 951 | if (e.key === "Enter" && !e.shiftKey) { |
| ... | ... | @@ -834,6 +953,18 @@ export default function ChatView() { |
| 834 | 953 | void onSend(e as unknown as FormEvent); |
| 835 | 954 | } |
| 836 | 955 | }; |
| 956 | const onToggleWorkGroup = useCallback((groupId: string) => { | |
| 957 | setExpandedWorkGroups((existing) => ({ | |
| 958 | ...existing, | |
| 959 | [groupId]: !existing[groupId], | |
| 960 | })); | |
| 961 | }, []); | |
| 962 | const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { | |
| 963 | setExpandedImage(image); | |
| 964 | }, []); | |
| 965 | const onToggleDiff = useCallback(() => { | |
| 966 | dispatch({ type: "TOGGLE_DIFF" }); | |
| 967 | }, [dispatch]); | |
| 837 | 968 | |
| 838 | 969 | // Empty state: no active thread |
| 839 | 970 | if (!activeThread) { |
| ... | ... | @@ -859,101 +990,24 @@ export default function ChatView() { |
| 859 | 990 | <header |
| 860 | 991 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} |
| 861 | 992 | > |
| 862 | <div className="flex items-center gap-3"> | |
| 863 | <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2> | |
| 864 | {activeProject && <Badge variant="outline">{activeProject.name}</Badge>} | |
| 865 | </div> | |
| 866 | <div className="flex items-center gap-3"> | |
| 867 | {/* Open in editor */} | |
| 868 | {activeProject && <OpenInPicker />} | |
| 869 | {/* Git actions */} | |
| 870 | {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />} | |
| 871 | ||
| 872 | {/* Diff toggle */} | |
| 873 | <Button | |
| 874 | size="xs" | |
| 875 | variant="ghost" | |
| 876 | className={cn( | |
| 877 | "text-muted-foreground/70 hover:text-foreground/80", | |
| 878 | state.diffOpen && "bg-accent text-accent-foreground", | |
| 879 | )} | |
| 880 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | |
| 881 | > | |
| 882 | Diff | |
| 883 | </Button> | |
| 884 | </div> | |
| 993 | <ChatHeader | |
| 994 | activeThreadTitle={activeThread.title} | |
| 995 | activeProjectName={activeProject?.name} | |
| 996 | keybindings={keybindings} | |
| 997 | api={api} | |
| 998 | gitCwd={gitCwd} | |
| 999 | diffOpen={state.diffOpen} | |
| 1000 | onToggleDiff={onToggleDiff} | |
| 1001 | /> | |
| 885 | 1002 | </header> |
| 886 | 1003 | |
| 887 | 1004 | {/* Error banner */} |
| 888 | {activeThread.error && ( | |
| 889 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 890 | <Alert variant="error"> | |
| 891 | <CircleAlertIcon /> | |
| 892 | <AlertDescription className="line-clamp-3" title={activeThread.error}> | |
| 893 | {activeThread.error} | |
| 894 | </AlertDescription> | |
| 895 | </Alert> | |
| 896 | </div> | |
| 897 | )} | |
| 898 | ||
| 899 | {pendingApprovals.length > 0 && ( | |
| 900 | <div className="pt-3 mx-auto max-w-3xl space-y-2"> | |
| 901 | {pendingApprovals.map((approval) => { | |
| 902 | const isResponding = respondingRequestIds.includes(approval.requestId); | |
| 903 | ||
| 904 | return ( | |
| 905 | <Alert variant="warning" key={approval.requestId}> | |
| 906 | <InfoIcon /> | |
| 907 | <AlertTitle className="text-xs"> | |
| 908 | {approval.requestKind === "command" | |
| 909 | ? "Command approval requested" | |
| 910 | : "File-change approval requested"} | |
| 911 | </AlertTitle> | |
| 912 | <AlertDescription | |
| 913 | className="truncate block font-mono text-[11px]" | |
| 914 | title={approval.detail} | |
| 915 | > | |
| 916 | {approval.detail} | |
| 917 | </AlertDescription> | |
| 918 | <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto"> | |
| 919 | <Button | |
| 920 | size="xs" | |
| 921 | variant="default" | |
| 922 | disabled={isResponding} | |
| 923 | onClick={() => void onRespondToApproval(approval.requestId, "accept")} | |
| 924 | > | |
| 925 | Approve once | |
| 926 | </Button> | |
| 927 | <Button | |
| 928 | size="xs" | |
| 929 | variant="outline" | |
| 930 | disabled={isResponding} | |
| 931 | onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} | |
| 932 | > | |
| 933 | Always allow this session | |
| 934 | </Button> | |
| 935 | <Button | |
| 936 | size="xs" | |
| 937 | variant="destructive-outline" | |
| 938 | disabled={isResponding} | |
| 939 | onClick={() => void onRespondToApproval(approval.requestId, "decline")} | |
| 940 | > | |
| 941 | Decline | |
| 942 | </Button> | |
| 943 | <Button | |
| 944 | size="xs" | |
| 945 | variant="ghost" | |
| 946 | disabled={isResponding} | |
| 947 | onClick={() => void onRespondToApproval(approval.requestId, "cancel")} | |
| 948 | > | |
| 949 | Cancel turn | |
| 950 | </Button> | |
| 951 | </AlertAction> | |
| 952 | </Alert> | |
| 953 | ); | |
| 954 | })} | |
| 955 | </div> | |
| 956 | )} | |
| 1005 | <ThreadErrorBanner error={activeThread.error} /> | |
| 1006 | <PendingApprovalsPanel | |
| 1007 | pendingApprovals={pendingApprovals} | |
| 1008 | respondingRequestIds={respondingRequestIds} | |
| 1009 | onRespondToApproval={onRespondToApproval} | |
| 1010 | /> | |
| 957 | 1011 | |
| 958 | 1012 | {/* Messages */} |
| 959 | 1013 | <div |
| ... | ... | @@ -961,198 +1015,19 @@ export default function ChatView() { |
| 961 | 1015 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" |
| 962 | 1016 | onScroll={onMessagesScroll} |
| 963 | 1017 | > |
| 964 | {activeThread.messages.length === 0 && !isWorking ? ( | |
| 965 | <div className="flex h-full items-center justify-center"> | |
| 966 | <p className="text-sm text-muted-foreground/30"> | |
| 967 | Send a message to start the conversation. | |
| 968 | </p> | |
| 969 | </div> | |
| 970 | ) : ( | |
| 971 | <div className="mx-auto max-w-3xl space-y-4"> | |
| 972 | {timelineEntries.map((timelineEntry, index) => { | |
| 973 | if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { | |
| 974 | return null; | |
| 975 | } | |
| 976 | ||
| 977 | const showCompletionDivider = | |
| 978 | timelineEntry.kind === "message" && | |
| 979 | timelineEntry.message.role === "assistant" && | |
| 980 | completionDividerBeforeEntryId === timelineEntry.id; | |
| 981 | ||
| 982 | if (timelineEntry.kind === "work") { | |
| 983 | const groupedEntries = [timelineEntry.entry]; | |
| 984 | let cursor = index + 1; | |
| 985 | while (cursor < timelineEntries.length) { | |
| 986 | const nextEntry = timelineEntries[cursor]; | |
| 987 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 988 | groupedEntries.push(nextEntry.entry); | |
| 989 | cursor += 1; | |
| 990 | } | |
| 991 | ||
| 992 | const groupId = timelineEntry.id; | |
| 993 | const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 994 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 995 | const visibleEntries = | |
| 996 | hasOverflow && !isExpanded | |
| 997 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 998 | : groupedEntries; | |
| 999 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 1000 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 1001 | const groupLabel = onlyToolEntries | |
| 1002 | ? groupedEntries.length === 1 | |
| 1003 | ? "Tool call" | |
| 1004 | : `Tool calls (${groupedEntries.length})` | |
| 1005 | : groupedEntries.length === 1 | |
| 1006 | ? "Work event" | |
| 1007 | : `Work log (${groupedEntries.length})`; | |
| 1008 | ||
| 1009 | return ( | |
| 1010 | <Fragment key={timelineEntry.id}> | |
| 1011 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 1012 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 1013 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 1014 | {groupLabel} | |
| 1015 | </p> | |
| 1016 | {hasOverflow && ( | |
| 1017 | <button | |
| 1018 | type="button" | |
| 1019 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 1020 | onClick={() => | |
| 1021 | setExpandedWorkGroups((existing) => ({ | |
| 1022 | ...existing, | |
| 1023 | [groupId]: !existing[groupId], | |
| 1024 | })) | |
| 1025 | } | |
| 1026 | > | |
| 1027 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 1028 | </button> | |
| 1029 | )} | |
| 1030 | </div> | |
| 1031 | <div className="space-y-1"> | |
| 1032 | {visibleEntries.map((workEntry) => ( | |
| 1033 | <div | |
| 1034 | key={`work-row:${workEntry.id}`} | |
| 1035 | className="flex items-start gap-2 py-0.5" | |
| 1036 | > | |
| 1037 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 1038 | <p | |
| 1039 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | |
| 1040 | > | |
| 1041 | {workEntry.detail ? ( | |
| 1042 | <> | |
| 1043 | {workEntry.label} | |
| 1044 | <span | |
| 1045 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | |
| 1046 | title={workEntry.detail} | |
| 1047 | > | |
| 1048 | {workEntry.detail} | |
| 1049 | </span> | |
| 1050 | </> | |
| 1051 | ) : ( | |
| 1052 | workEntry.label | |
| 1053 | )} | |
| 1054 | </p> | |
| 1055 | </div> | |
| 1056 | ))} | |
| 1057 | </div> | |
| 1058 | </div> | |
| 1059 | </Fragment> | |
| 1060 | ); | |
| 1061 | } | |
| 1062 | ||
| 1063 | if (timelineEntry.message.role === "user") { | |
| 1064 | const userImages = timelineEntry.message.attachments ?? []; | |
| 1065 | return ( | |
| 1066 | <Fragment key={timelineEntry.id}> | |
| 1067 | <div className="flex justify-end"> | |
| 1068 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 1069 | {userImages.length > 0 && ( | |
| 1070 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 1071 | {userImages.map((image) => ( | |
| 1072 | <div | |
| 1073 | key={image.id} | |
| 1074 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 1075 | > | |
| 1076 | {image.previewUrl ? ( | |
| 1077 | <img | |
| 1078 | src={image.previewUrl} | |
| 1079 | alt={image.name} | |
| 1080 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | |
| 1081 | onClick={() => | |
| 1082 | setExpandedImage({ src: image.previewUrl!, name: image.name }) | |
| 1083 | } | |
| 1084 | /> | |
| 1085 | ) : ( | |
| 1086 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 1087 | {image.name} | |
| 1088 | </div> | |
| 1089 | )} | |
| 1090 | </div> | |
| 1091 | ))} | |
| 1092 | </div> | |
| 1093 | )} | |
| 1094 | {timelineEntry.message.text && ( | |
| 1095 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 1096 | {timelineEntry.message.text} | |
| 1097 | </pre> | |
| 1098 | )} | |
| 1099 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | |
| 1100 | {formatTimestamp(timelineEntry.message.createdAt)} | |
| 1101 | </p> | |
| 1102 | </div> | |
| 1103 | </div> | |
| 1104 | </Fragment> | |
| 1105 | ); | |
| 1106 | } | |
| 1107 | ||
| 1108 | return ( | |
| 1109 | <Fragment key={timelineEntry.id}> | |
| 1110 | {showCompletionDivider && ( | |
| 1111 | <div className="my-3 flex items-center gap-3"> | |
| 1112 | <span className="h-px flex-1 bg-border" /> | |
| 1113 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 1114 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | |
| 1115 | </span> | |
| 1116 | <span className="h-px flex-1 bg-border" /> | |
| 1117 | </div> | |
| 1118 | )} | |
| 1119 | <div className="px-1 py-0.5"> | |
| 1120 | <ChatMarkdown | |
| 1121 | text={ | |
| 1122 | timelineEntry.message.text || | |
| 1123 | (timelineEntry.message.streaming ? "" : "(empty response)") | |
| 1124 | } | |
| 1125 | /> | |
| 1126 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | |
| 1127 | {formatMessageMeta( | |
| 1128 | timelineEntry.message.createdAt, | |
| 1129 | timelineEntry.message.streaming | |
| 1130 | ? formatElapsed(timelineEntry.message.createdAt, nowIso) | |
| 1131 | : formatElapsed( | |
| 1132 | timelineEntry.message.createdAt, | |
| 1133 | assistantCompletionByItemId.get(timelineEntry.message.id), | |
| 1134 | ), | |
| 1135 | )} | |
| 1136 | </p> | |
| 1137 | </div> | |
| 1138 | </Fragment> | |
| 1139 | ); | |
| 1140 | })} | |
| 1141 | {isWorking && ( | |
| 1142 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | |
| 1143 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 1144 | <div className="flex items-center pt-1"> | |
| 1145 | <span className="inline-flex items-center gap-[3px]"> | |
| 1146 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 1147 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 1148 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 1149 | </span> | |
| 1150 | </div> | |
| 1151 | </div> | |
| 1152 | )} | |
| 1153 | <div ref={messagesEndRef} /> | |
| 1154 | </div> | |
| 1155 | )} | |
| 1018 | <MessagesTimeline | |
| 1019 | hasMessages={activeThread.messages.length > 0} | |
| 1020 | isWorking={isWorking} | |
| 1021 | timelineEntries={timelineEntries} | |
| 1022 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} | |
| 1023 | completionSummary={completionSummary} | |
| 1024 | assistantCompletionByItemId={assistantCompletionByItemId} | |
| 1025 | nowIso={nowIso} | |
| 1026 | expandedWorkGroups={expandedWorkGroups} | |
| 1027 | onToggleWorkGroup={onToggleWorkGroup} | |
| 1028 | onImageExpand={onExpandTimelineImage} | |
| 1029 | messagesEndRef={messagesEndRef} | |
| 1030 | /> | |
| 1156 | 1031 | </div> |
| 1157 | 1032 | |
| 1158 | 1033 | {/* Input bar */} |
| ... | ... | @@ -1230,7 +1105,7 @@ export default function ChatView() { |
| 1230 | 1105 | <Separator orientation="vertical" className="mx-0.5 h-4" /> |
| 1231 | 1106 | |
| 1232 | 1107 | {/* Reasoning effort */} |
| 1233 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={setSelectedEffort} /> | |
| 1108 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} /> | |
| 1234 | 1109 | |
| 1235 | 1110 | {/* Divider */} |
| 1236 | 1111 | <Separator orientation="vertical" className="mx-0.5 h-4" /> |
| ... | ... | @@ -1333,7 +1208,12 @@ export default function ChatView() { |
| 1333 | 1208 | </div> |
| 1334 | 1209 | |
| 1335 | 1210 | {isGitRepo && ( |
| 1336 | <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} /> | |
| 1211 | <BranchToolbar | |
| 1212 | envMode={envMode} | |
| 1213 | onEnvModeChange={onEnvModeChange} | |
| 1214 | envLocked={envLocked} | |
| 1215 | onComposerFocusRequest={scheduleComposerFocus} | |
| 1216 | /> | |
| 1337 | 1217 | )} |
| 1338 | 1218 | |
| 1339 | 1219 | {activeThread.terminalOpen && api && activeProject && ( |
| ... | ... | @@ -1350,6 +1230,9 @@ export default function ChatView() { |
| 1350 | 1230 | focusRequestId={terminalFocusRequestId} |
| 1351 | 1231 | onSplitTerminal={splitTerminal} |
| 1352 | 1232 | onNewTerminal={createNewTerminal} |
| 1233 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 1234 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 1235 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 1353 | 1236 | onActiveTerminalChange={activateTerminal} |
| 1354 | 1237 | onCloseTerminal={closeTerminal} |
| 1355 | 1238 | onHeightChange={(height) => |
| ... | ... | @@ -1400,7 +1283,355 @@ export default function ChatView() { |
| 1400 | 1283 | ); |
| 1401 | 1284 | } |
| 1402 | 1285 | |
| 1403 | function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) { | |
| 1286 | interface ChatHeaderProps { | |
| 1287 | activeThreadTitle: string; | |
| 1288 | activeProjectName: string | undefined; | |
| 1289 | keybindings: ResolvedKeybindingsConfig; | |
| 1290 | api: NativeApi | undefined; | |
| 1291 | gitCwd: string | null; | |
| 1292 | diffOpen: boolean; | |
| 1293 | onToggleDiff: () => void; | |
| 1294 | } | |
| 1295 | ||
| 1296 | const ChatHeader = memo(function ChatHeader({ | |
| 1297 | activeThreadTitle, | |
| 1298 | activeProjectName, | |
| 1299 | keybindings, | |
| 1300 | api, | |
| 1301 | gitCwd, | |
| 1302 | diffOpen, | |
| 1303 | onToggleDiff, | |
| 1304 | }: ChatHeaderProps) { | |
| 1305 | return ( | |
| 1306 | <> | |
| 1307 | <div className="flex items-center gap-3"> | |
| 1308 | <h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2> | |
| 1309 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} | |
| 1310 | </div> | |
| 1311 | <div className="flex items-center gap-3"> | |
| 1312 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | |
| 1313 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | |
| 1314 | <Button | |
| 1315 | size="xs" | |
| 1316 | variant="ghost" | |
| 1317 | className={cn( | |
| 1318 | "text-muted-foreground/70 hover:text-foreground/80", | |
| 1319 | diffOpen && "bg-accent text-accent-foreground", | |
| 1320 | )} | |
| 1321 | onClick={onToggleDiff} | |
| 1322 | > | |
| 1323 | Diff | |
| 1324 | </Button> | |
| 1325 | </div> | |
| 1326 | </> | |
| 1327 | ); | |
| 1328 | }); | |
| 1329 | ||
| 1330 | const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { | |
| 1331 | if (!error) return null; | |
| 1332 | return ( | |
| 1333 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 1334 | <Alert variant="error"> | |
| 1335 | <CircleAlertIcon /> | |
| 1336 | <AlertDescription className="line-clamp-3" title={error}> | |
| 1337 | {error} | |
| 1338 | </AlertDescription> | |
| 1339 | </Alert> | |
| 1340 | </div> | |
| 1341 | ); | |
| 1342 | }); | |
| 1343 | ||
| 1344 | interface PendingApprovalsPanelProps { | |
| 1345 | pendingApprovals: PendingApproval[]; | |
| 1346 | respondingRequestIds: string[]; | |
| 1347 | onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>; | |
| 1348 | } | |
| 1349 | ||
| 1350 | const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ | |
| 1351 | pendingApprovals, | |
| 1352 | respondingRequestIds, | |
| 1353 | onRespondToApproval, | |
| 1354 | }: PendingApprovalsPanelProps) { | |
| 1355 | if (pendingApprovals.length === 0) return null; | |
| 1356 | return ( | |
| 1357 | <div className="pt-3 mx-auto max-w-3xl space-y-2"> | |
| 1358 | {pendingApprovals.map((approval) => { | |
| 1359 | const isResponding = respondingRequestIds.includes(approval.requestId); | |
| 1360 | ||
| 1361 | return ( | |
| 1362 | <Alert variant="warning" key={approval.requestId}> | |
| 1363 | <InfoIcon /> | |
| 1364 | <AlertTitle className="text-xs"> | |
| 1365 | {approval.requestKind === "command" | |
| 1366 | ? "Command approval requested" | |
| 1367 | : "File-change approval requested"} | |
| 1368 | </AlertTitle> | |
| 1369 | <AlertDescription | |
| 1370 | className="truncate block font-mono text-[11px]" | |
| 1371 | title={approval.detail} | |
| 1372 | > | |
| 1373 | {approval.detail} | |
| 1374 | </AlertDescription> | |
| 1375 | <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto"> | |
| 1376 | <Button | |
| 1377 | size="xs" | |
| 1378 | variant="default" | |
| 1379 | disabled={isResponding} | |
| 1380 | onClick={() => void onRespondToApproval(approval.requestId, "accept")} | |
| 1381 | > | |
| 1382 | Approve once | |
| 1383 | </Button> | |
| 1384 | <Button | |
| 1385 | size="xs" | |
| 1386 | variant="outline" | |
| 1387 | disabled={isResponding} | |
| 1388 | onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} | |
| 1389 | > | |
| 1390 | Always allow this session | |
| 1391 | </Button> | |
| 1392 | <Button | |
| 1393 | size="xs" | |
| 1394 | variant="destructive-outline" | |
| 1395 | disabled={isResponding} | |
| 1396 | onClick={() => void onRespondToApproval(approval.requestId, "decline")} | |
| 1397 | > | |
| 1398 | Decline | |
| 1399 | </Button> | |
| 1400 | <Button | |
| 1401 | size="xs" | |
| 1402 | variant="ghost" | |
| 1403 | disabled={isResponding} | |
| 1404 | onClick={() => void onRespondToApproval(approval.requestId, "cancel")} | |
| 1405 | > | |
| 1406 | Cancel turn | |
| 1407 | </Button> | |
| 1408 | </AlertAction> | |
| 1409 | </Alert> | |
| 1410 | ); | |
| 1411 | })} | |
| 1412 | </div> | |
| 1413 | ); | |
| 1414 | }); | |
| 1415 | ||
| 1416 | interface MessagesTimelineProps { | |
| 1417 | hasMessages: boolean; | |
| 1418 | isWorking: boolean; | |
| 1419 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | |
| 1420 | completionDividerBeforeEntryId: string | null; | |
| 1421 | completionSummary: string | null; | |
| 1422 | assistantCompletionByItemId: Map<string, string>; | |
| 1423 | nowIso: string; | |
| 1424 | expandedWorkGroups: Record<string, boolean>; | |
| 1425 | onToggleWorkGroup: (groupId: string) => void; | |
| 1426 | onImageExpand: (image: ExpandedImagePreview) => void; | |
| 1427 | messagesEndRef: RefObject<HTMLDivElement | null>; | |
| 1428 | } | |
| 1429 | ||
| 1430 | const MessagesTimeline = memo(function MessagesTimeline({ | |
| 1431 | hasMessages, | |
| 1432 | isWorking, | |
| 1433 | timelineEntries, | |
| 1434 | completionDividerBeforeEntryId, | |
| 1435 | completionSummary, | |
| 1436 | assistantCompletionByItemId, | |
| 1437 | nowIso, | |
| 1438 | expandedWorkGroups, | |
| 1439 | onToggleWorkGroup, | |
| 1440 | onImageExpand, | |
| 1441 | messagesEndRef, | |
| 1442 | }: MessagesTimelineProps) { | |
| 1443 | if (!hasMessages && !isWorking) { | |
| 1444 | return ( | |
| 1445 | <div className="flex h-full items-center justify-center"> | |
| 1446 | <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p> | |
| 1447 | </div> | |
| 1448 | ); | |
| 1449 | } | |
| 1450 | ||
| 1451 | return ( | |
| 1452 | <div className="mx-auto max-w-3xl space-y-4"> | |
| 1453 | {timelineEntries.map((timelineEntry, index) => { | |
| 1454 | if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { | |
| 1455 | return null; | |
| 1456 | } | |
| 1457 | ||
| 1458 | const showCompletionDivider = | |
| 1459 | timelineEntry.kind === "message" && | |
| 1460 | timelineEntry.message.role === "assistant" && | |
| 1461 | completionDividerBeforeEntryId === timelineEntry.id; | |
| 1462 | ||
| 1463 | if (timelineEntry.kind === "work") { | |
| 1464 | const groupedEntries = [timelineEntry.entry]; | |
| 1465 | let cursor = index + 1; | |
| 1466 | while (cursor < timelineEntries.length) { | |
| 1467 | const nextEntry = timelineEntries[cursor]; | |
| 1468 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 1469 | groupedEntries.push(nextEntry.entry); | |
| 1470 | cursor += 1; | |
| 1471 | } | |
| 1472 | ||
| 1473 | const groupId = timelineEntry.id; | |
| 1474 | const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 1475 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 1476 | const visibleEntries = | |
| 1477 | hasOverflow && !isExpanded | |
| 1478 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 1479 | : groupedEntries; | |
| 1480 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 1481 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 1482 | const groupLabel = onlyToolEntries | |
| 1483 | ? groupedEntries.length === 1 | |
| 1484 | ? "Tool call" | |
| 1485 | : `Tool calls (${groupedEntries.length})` | |
| 1486 | : groupedEntries.length === 1 | |
| 1487 | ? "Work event" | |
| 1488 | : `Work log (${groupedEntries.length})`; | |
| 1489 | ||
| 1490 | return ( | |
| 1491 | <Fragment key={timelineEntry.id}> | |
| 1492 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 1493 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 1494 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 1495 | {groupLabel} | |
| 1496 | </p> | |
| 1497 | {hasOverflow && ( | |
| 1498 | <button | |
| 1499 | type="button" | |
| 1500 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 1501 | onClick={() => onToggleWorkGroup(groupId)} | |
| 1502 | > | |
| 1503 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 1504 | </button> | |
| 1505 | )} | |
| 1506 | </div> | |
| 1507 | <div className="space-y-1"> | |
| 1508 | {visibleEntries.map((workEntry) => ( | |
| 1509 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | |
| 1510 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 1511 | <p | |
| 1512 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | |
| 1513 | > | |
| 1514 | {workEntry.detail ? ( | |
| 1515 | <> | |
| 1516 | {workEntry.label} | |
| 1517 | <span | |
| 1518 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | |
| 1519 | title={workEntry.detail} | |
| 1520 | > | |
| 1521 | {workEntry.detail} | |
| 1522 | </span> | |
| 1523 | </> | |
| 1524 | ) : ( | |
| 1525 | workEntry.label | |
| 1526 | )} | |
| 1527 | </p> | |
| 1528 | </div> | |
| 1529 | ))} | |
| 1530 | </div> | |
| 1531 | </div> | |
| 1532 | </Fragment> | |
| 1533 | ); | |
| 1534 | } | |
| 1535 | ||
| 1536 | if (timelineEntry.message.role === "user") { | |
| 1537 | const userImages = timelineEntry.message.attachments ?? []; | |
| 1538 | return ( | |
| 1539 | <Fragment key={timelineEntry.id}> | |
| 1540 | <div className="flex justify-end"> | |
| 1541 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 1542 | {userImages.length > 0 && ( | |
| 1543 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 1544 | {userImages.map((image) => ( | |
| 1545 | <div | |
| 1546 | key={image.id} | |
| 1547 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 1548 | > | |
| 1549 | {image.previewUrl ? ( | |
| 1550 | <img | |
| 1551 | src={image.previewUrl} | |
| 1552 | alt={image.name} | |
| 1553 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | |
| 1554 | onClick={() => | |
| 1555 | onImageExpand({ src: image.previewUrl!, name: image.name }) | |
| 1556 | } | |
| 1557 | /> | |
| 1558 | ) : ( | |
| 1559 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 1560 | {image.name} | |
| 1561 | </div> | |
| 1562 | )} | |
| 1563 | </div> | |
| 1564 | ))} | |
| 1565 | </div> | |
| 1566 | )} | |
| 1567 | {timelineEntry.message.text && ( | |
| 1568 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 1569 | {timelineEntry.message.text} | |
| 1570 | </pre> | |
| 1571 | )} | |
| 1572 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | |
| 1573 | {formatTimestamp(timelineEntry.message.createdAt)} | |
| 1574 | </p> | |
| 1575 | </div> | |
| 1576 | </div> | |
| 1577 | </Fragment> | |
| 1578 | ); | |
| 1579 | } | |
| 1580 | ||
| 1581 | return ( | |
| 1582 | <Fragment key={timelineEntry.id}> | |
| 1583 | {showCompletionDivider && ( | |
| 1584 | <div className="my-3 flex items-center gap-3"> | |
| 1585 | <span className="h-px flex-1 bg-border" /> | |
| 1586 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 1587 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | |
| 1588 | </span> | |
| 1589 | <span className="h-px flex-1 bg-border" /> | |
| 1590 | </div> | |
| 1591 | )} | |
| 1592 | <div className="px-1 py-0.5"> | |
| 1593 | <ChatMarkdown | |
| 1594 | text={ | |
| 1595 | timelineEntry.message.text || | |
| 1596 | (timelineEntry.message.streaming ? "" : "(empty response)") | |
| 1597 | } | |
| 1598 | /> | |
| 1599 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | |
| 1600 | {formatMessageMeta( | |
| 1601 | timelineEntry.message.createdAt, | |
| 1602 | timelineEntry.message.streaming | |
| 1603 | ? formatElapsed(timelineEntry.message.createdAt, nowIso) | |
| 1604 | : formatElapsed( | |
| 1605 | timelineEntry.message.createdAt, | |
| 1606 | assistantCompletionByItemId.get(timelineEntry.message.id), | |
| 1607 | ), | |
| 1608 | )} | |
| 1609 | </p> | |
| 1610 | </div> | |
| 1611 | </Fragment> | |
| 1612 | ); | |
| 1613 | })} | |
| 1614 | {isWorking && ( | |
| 1615 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | |
| 1616 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 1617 | <div className="flex items-center pt-1"> | |
| 1618 | <span className="inline-flex items-center gap-[3px]"> | |
| 1619 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 1620 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 1621 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 1622 | </span> | |
| 1623 | </div> | |
| 1624 | </div> | |
| 1625 | )} | |
| 1626 | <div ref={messagesEndRef} /> | |
| 1627 | </div> | |
| 1628 | ); | |
| 1629 | }); | |
| 1630 | ||
| 1631 | const ModelPicker = memo(function ModelPicker(props: { | |
| 1632 | model: ModelSlug; | |
| 1633 | onModelChange: (model: ModelSlug) => void; | |
| 1634 | }) { | |
| 1404 | 1635 | return ( |
| 1405 | 1636 | <Select |
| 1406 | 1637 | items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))} |
| ... | ... | @@ -1419,9 +1650,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug |
| 1419 | 1650 | </SelectPopup> |
| 1420 | 1651 | </Select> |
| 1421 | 1652 | ); |
| 1422 | } | |
| 1653 | }); | |
| 1423 | 1654 | |
| 1424 | function ReasoningEffortPicker(props: { | |
| 1655 | const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { | |
| 1425 | 1656 | effort: ReasoningEffort; |
| 1426 | 1657 | onEffortChange: (effort: ReasoningEffort) => void; |
| 1427 | 1658 | }) { |
| ... | ... | @@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: { |
| 1443 | 1674 | </SelectPopup> |
| 1444 | 1675 | </Select> |
| 1445 | 1676 | ); |
| 1446 | } | |
| 1677 | }); | |
| 1447 | 1678 | |
| 1448 | function OpenInPicker() { | |
| 1679 | const OpenInPicker = memo(function OpenInPicker({ | |
| 1680 | keybindings, | |
| 1681 | }: { | |
| 1682 | keybindings: ResolvedKeybindingsConfig; | |
| 1683 | }) { | |
| 1449 | 1684 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 1450 | 1685 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 1451 | 1686 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; |
| ... | ... | @@ -1458,9 +1693,9 @@ function OpenInPicker() { |
| 1458 | 1693 | value: "cursor", |
| 1459 | 1694 | }, |
| 1460 | 1695 | { |
| 1461 | label: navigator.platform.includes("Mac") | |
| 1696 | label: isMacPlatform(navigator.platform) | |
| 1462 | 1697 | ? "Finder" |
| 1463 | : navigator.platform.includes("Win") | |
| 1698 | : isWindowsPlatform(navigator.platform) | |
| 1464 | 1699 | ? "Explorer" |
| 1465 | 1700 | : "Files", |
| 1466 | 1701 | Icon: FolderClosedIcon, |
| ... | ... | @@ -1486,20 +1721,23 @@ function OpenInPicker() { |
| 1486 | 1721 | [api, activeProject, activeThread, lastEditor, setLastEditor], |
| 1487 | 1722 | ); |
| 1488 | 1723 | |
| 1489 | // Cmd+O / Ctrl+O to open in last-used editor | |
| 1724 | const openFavoriteEditorShortcutLabel = useMemo( | |
| 1725 | () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), | |
| 1726 | [keybindings], | |
| 1727 | ); | |
| 1728 | ||
| 1490 | 1729 | useEffect(() => { |
| 1491 | 1730 | const handler = (e: globalThis.KeyboardEvent) => { |
| 1492 | if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { | |
| 1493 | if (api && activeProject) { | |
| 1494 | e.preventDefault(); | |
| 1495 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; | |
| 1496 | void api.shell.openInEditor(cwd, lastEditor); | |
| 1497 | } | |
| 1498 | } | |
| 1731 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | |
| 1732 | if (!api || !activeProject) return; | |
| 1733 | ||
| 1734 | e.preventDefault(); | |
| 1735 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; | |
| 1736 | void api.shell.openInEditor(cwd, lastEditor); | |
| 1499 | 1737 | }; |
| 1500 | 1738 | window.addEventListener("keydown", handler); |
| 1501 | 1739 | return () => window.removeEventListener("keydown", handler); |
| 1502 | }, [api, activeProject, activeThread, lastEditor]); | |
| 1740 | }, [api, activeProject, activeThread, keybindings, lastEditor]); | |
| 1503 | 1741 | |
| 1504 | 1742 | return ( |
| 1505 | 1743 | <Group aria-label="Subscription actions"> |
| ... | ... | @@ -1517,10 +1755,8 @@ function OpenInPicker() { |
| 1517 | 1755 | <MenuItem key={value} onClick={() => openInEditor(value)}> |
| 1518 | 1756 | <Icon aria-hidden="true" className="text-muted-foreground" /> |
| 1519 | 1757 | {label} |
| 1520 | {value === lastEditor && ( | |
| 1521 | <MenuShortcut> | |
| 1522 | {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"} | |
| 1523 | </MenuShortcut> | |
| 1758 | {value === lastEditor && openFavoriteEditorShortcutLabel && ( | |
| 1759 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 1524 | 1760 | )} |
| 1525 | 1761 | </MenuItem> |
| 1526 | 1762 | ))} |
| ... | ... | @@ -1528,4 +1764,4 @@ function OpenInPicker() { |
| 1528 | 1764 | </Menu> |
| 1529 | 1765 | </Group> |
| 1530 | 1766 | ); |
| 1531 | } | |
| 1767 | }); |
apps/web/src/components/GitActionsControl.logic.test.ts+23-17| ... | ... | @@ -20,8 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 20 | 20 | hasUpstream: true, |
| 21 | 21 | aheadCount: 0, |
| 22 | 22 | behindCount: 0, |
| 23 | openPr: null, | |
| 24 | mergedPr: null, | |
| 23 | pr: null, | |
| 25 | 24 | ...overrides, |
| 26 | 25 | }; |
| 27 | 26 | } |
| ... | ... | @@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => { |
| 30 | 29 | it("resolveQuickAction opens the existing PR", () => { |
| 31 | 30 | const quick = resolveQuickAction( |
| 32 | 31 | status({ |
| 33 | openPr: { | |
| 32 | pr: { | |
| 34 | 33 | number: 10, |
| 35 | 34 | title: "Open PR", |
| 36 | 35 | url: "https://example.com/pr/10", |
| 37 | 36 | baseBranch: "main", |
| 38 | 37 | headBranch: "feature/test", |
| 38 | state: "open", | |
| 39 | 39 | }, |
| 40 | 40 | }), |
| 41 | 41 | false, |
| ... | ... | @@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => { |
| 46 | 46 | it("buildMenuItems disables commit/push and enables open PR", () => { |
| 47 | 47 | const items = buildMenuItems( |
| 48 | 48 | status({ |
| 49 | openPr: { | |
| 49 | pr: { | |
| 50 | 50 | number: 11, |
| 51 | 51 | title: "Existing PR", |
| 52 | 52 | url: "https://example.com/pr/11", |
| 53 | 53 | baseBranch: "main", |
| 54 | 54 | headBranch: "feature/test", |
| 55 | state: "open", | |
| 55 | 56 | }, |
| 56 | 57 | }), |
| 57 | 58 | false, |
| ... | ... | @@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 148 | 149 | const quick = resolveQuickAction( |
| 149 | 150 | status({ |
| 150 | 151 | aheadCount: 3, |
| 151 | openPr: { | |
| 152 | pr: { | |
| 152 | 153 | number: 13, |
| 153 | 154 | title: "Open PR", |
| 154 | 155 | url: "https://example.com/pr/13", |
| 155 | 156 | baseBranch: "main", |
| 156 | 157 | headBranch: "feature/test", |
| 158 | state: "open", | |
| 157 | 159 | }, |
| 158 | 160 | }), |
| 159 | 161 | false, |
| ... | ... | @@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 165 | 167 | const items = buildMenuItems( |
| 166 | 168 | status({ |
| 167 | 169 | aheadCount: 2, |
| 168 | openPr: { | |
| 170 | pr: { | |
| 169 | 171 | number: 12, |
| 170 | 172 | title: "Existing PR", |
| 171 | 173 | url: "https://example.com/pr/12", |
| 172 | 174 | baseBranch: "main", |
| 173 | 175 | headBranch: "feature/test", |
| 176 | state: "open", | |
| 174 | 177 | }, |
| 175 | 178 | }), |
| 176 | 179 | false, |
| ... | ... | @@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 205 | 208 | |
| 206 | 209 | describe("when: branch is clean, ahead, and has no open PR", () => { |
| 207 | 210 | it("resolveQuickAction pushes and creates a PR", () => { |
| 208 | const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false); | |
| 211 | const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); | |
| 209 | 212 | assert.deepInclude(quick, { |
| 210 | 213 | kind: "run_action", |
| 211 | 214 | action: "commit_push_pr", |
| ... | ... | @@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 214 | 217 | }); |
| 215 | 218 | |
| 216 | 219 | it("buildMenuItems enables push and create PR, with commit disabled", () => { |
| 217 | const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false); | |
| 220 | const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); | |
| 218 | 221 | assert.deepEqual(items, [ |
| 219 | 222 | { |
| 220 | 223 | id: "commit", |
| ... | ... | @@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 247 | 250 | describe("when: branch is clean, up to date, and has no open PR", () => { |
| 248 | 251 | it("resolveQuickAction returns disabled no-action state", () => { |
| 249 | 252 | const quick = resolveQuickAction( |
| 250 | status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }), | |
| 253 | status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), | |
| 251 | 254 | false, |
| 252 | 255 | ); |
| 253 | 256 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); |
| 254 | 257 | }); |
| 255 | 258 | |
| 256 | 259 | it("buildMenuItems disables commit, push, and create PR", () => { |
| 257 | const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false); | |
| 260 | const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); | |
| 258 | 261 | assert.deepEqual(items, [ |
| 259 | 262 | { |
| 260 | 263 | id: "commit", |
| ... | ... | @@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => { |
| 291 | 294 | }); |
| 292 | 295 | |
| 293 | 296 | it("buildMenuItems disables push and create PR", () => { |
| 294 | const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false); | |
| 297 | const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); | |
| 295 | 298 | assert.deepEqual(items, [ |
| 296 | 299 | { |
| 297 | 300 | id: "commit", |
| ... | ... | @@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => { |
| 347 | 350 | const quick = resolveQuickAction( |
| 348 | 351 | status({ |
| 349 | 352 | hasWorkingTreeChanges: true, |
| 350 | openPr: { | |
| 353 | pr: { | |
| 351 | 354 | number: 16, |
| 352 | 355 | title: "Existing PR", |
| 353 | 356 | url: "https://example.com/pr/16", |
| 354 | 357 | baseBranch: "main", |
| 355 | 358 | headBranch: "feature/test", |
| 359 | state: "open", | |
| 356 | 360 | }, |
| 357 | 361 | }), |
| 358 | 362 | false, |
| ... | ... | @@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => { |
| 412 | 416 | |
| 413 | 417 | it("resolveQuickAction returns push when branch is ahead", () => { |
| 414 | 418 | const quick = resolveQuickAction( |
| 415 | status({ branch: "main", aheadCount: 2, openPr: null }), | |
| 419 | status({ branch: "main", aheadCount: 2, pr: null }), | |
| 416 | 420 | false, |
| 417 | 421 | true, |
| 418 | 422 | ); |
| ... | ... | @@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 512 | 516 | describe("when: branch has no upstream configured", () => { |
| 513 | 517 | it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { |
| 514 | 518 | const quick = resolveQuickAction( |
| 515 | status({ hasUpstream: false, openPr: null, aheadCount: 0 }), | |
| 519 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), | |
| 516 | 520 | false, |
| 517 | 521 | ); |
| 518 | 522 | assert.deepInclude(quick, { |
| ... | ... | @@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => { |
| 527 | 531 | status({ |
| 528 | 532 | hasUpstream: false, |
| 529 | 533 | aheadCount: 0, |
| 530 | openPr: { | |
| 534 | pr: { | |
| 531 | 535 | number: 14, |
| 532 | 536 | title: "Existing PR", |
| 533 | 537 | url: "https://example.com/pr/14", |
| 534 | 538 | baseBranch: "main", |
| 535 | 539 | headBranch: "feature/test", |
| 540 | state: "open", | |
| 536 | 541 | }, |
| 537 | 542 | }), |
| 538 | 543 | false, |
| ... | ... | @@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => { |
| 549 | 554 | status({ |
| 550 | 555 | hasUpstream: false, |
| 551 | 556 | aheadCount: 1, |
| 552 | openPr: { | |
| 557 | pr: { | |
| 553 | 558 | number: 15, |
| 554 | 559 | title: "Existing PR", |
| 555 | 560 | url: "https://example.com/pr/15", |
| 556 | 561 | baseBranch: "main", |
| 557 | 562 | headBranch: "feature/test", |
| 563 | state: "open", | |
| 558 | 564 | }, |
| 559 | 565 | }), |
| 560 | 566 | false, |
| ... | ... | @@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => { |
| 569 | 575 | |
| 570 | 576 | it("buildMenuItems disables push and create PR when no commits are ahead", () => { |
| 571 | 577 | const items = buildMenuItems( |
| 572 | status({ hasUpstream: false, openPr: null, aheadCount: 0 }), | |
| 578 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), | |
| 573 | 579 | false, |
| 574 | 580 | ); |
| 575 | 581 | assert.deepEqual(items, [ |
apps/web/src/components/GitActionsControl.logic.ts+2-2| ... | ... | @@ -108,7 +108,7 @@ export function buildMenuItems( |
| 108 | 108 | |
| 109 | 109 | const hasBranch = gitStatus.branch !== null; |
| 110 | 110 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 111 | const hasOpenPr = gitStatus.openPr !== null; | |
| 111 | const hasOpenPr = gitStatus.pr?.state === "open"; | |
| 112 | 112 | const isBehind = gitStatus.behindCount > 0; |
| 113 | 113 | const canCommit = !isBusy && hasChanges; |
| 114 | 114 | const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; |
| ... | ... | @@ -178,7 +178,7 @@ export function resolveQuickAction( |
| 178 | 178 | |
| 179 | 179 | const hasBranch = gitStatus.branch !== null; |
| 180 | 180 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 181 | const hasOpenPr = gitStatus.openPr !== null; | |
| 181 | const hasOpenPr = gitStatus.pr?.state === "open"; | |
| 182 | 182 | const isAhead = gitStatus.aheadCount > 0; |
| 183 | 183 | const isBehind = gitStatus.behindCount > 0; |
| 184 | 184 | const isDiverged = isAhead && isBehind; |
apps/web/src/components/GitActionsControl.tsx+11-6| ... | ... | @@ -57,7 +57,7 @@ function getMenuActionDisabledReason( |
| 57 | 57 | |
| 58 | 58 | const hasBranch = gitStatus.branch !== null; |
| 59 | 59 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 60 | const hasOpenPr = gitStatus.openPr !== null; | |
| 60 | const hasOpenPr = gitStatus.pr?.state === "open"; | |
| 61 | 61 | const isAhead = gitStatus.aheadCount > 0; |
| 62 | 62 | const isBehind = gitStatus.behindCount > 0; |
| 63 | 63 | |
| ... | ... | @@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 217 | 217 | }); |
| 218 | 218 | return; |
| 219 | 219 | } |
| 220 | const prUrl = gitStatusForActions?.openPr?.url ?? null; | |
| 220 | const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; | |
| 221 | 221 | if (!prUrl) { |
| 222 | 222 | toastManager.add({ |
| 223 | 223 | type: "error", |
| ... | ... | @@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 237 | 237 | }), |
| 238 | 238 | }); |
| 239 | 239 | void promise.catch(() => undefined); |
| 240 | }, [api, gitStatusForActions?.openPr?.url, threadToastData]); | |
| 240 | }, [api, gitStatusForActions?.pr, threadToastData]); | |
| 241 | 241 | |
| 242 | 242 | const runGitActionWithToast = useCallback( |
| 243 | 243 | async ({ |
| ... | ... | @@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 297 | 297 | stopProgressUpdates(); |
| 298 | 298 | const resultToast = summarizeGitResult(result); |
| 299 | 299 | |
| 300 | const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url; | |
| 300 | const existingOpenPrUrl = | |
| 301 | gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; | |
| 302 | const prUrl = result.pr.url ?? existingOpenPrUrl; | |
| 301 | 303 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 302 | 304 | const shouldOfferOpenPrCta = |
| 303 | 305 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; |
| ... | ... | @@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 312 | 314 | title: resultToast.title, |
| 313 | 315 | description: resultToast.description, |
| 314 | 316 | timeout: 0, |
| 315 | data: threadToastData, | |
| 317 | data: { | |
| 318 | ...threadToastData, | |
| 319 | dismissAfterVisibleMs: 10_000, | |
| 320 | }, | |
| 316 | 321 | ...(shouldOfferPushCta |
| 317 | 322 | ? { |
| 318 | 323 | actionProps: { |
| ... | ... | @@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 362 | 367 | api, |
| 363 | 368 | gitStatusForActions?.branch, |
| 364 | 369 | gitStatusForActions?.hasWorkingTreeChanges, |
| 365 | gitStatusForActions?.openPr?.url, | |
| 370 | gitStatusForActions?.pr, | |
| 366 | 371 | isDefaultBranch, |
| 367 | 372 | maybeConfirmPushToDefaultBranch, |
| 368 | 373 | runImmediateGitActionMutation, |
apps/web/src/components/Sidebar.tsx+140-32| ... | ... | @@ -1,12 +1,20 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | |
| 1 | import { | |
| 2 | GitPullRequestIcon, | |
| 3 | MonitorIcon, | |
| 4 | MoonIcon, | |
| 5 | SunIcon, | |
| 6 | TerminalIcon, | |
| 7 | } from "lucide-react"; | |
| 2 | 8 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; | |
| 9 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 10 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 4 | 11 | import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; |
| 5 | 12 | import { isElectron } from "../env"; |
| 6 | 13 | import { useTheme } from "../hooks/useTheme"; |
| 7 | 14 | import { DEFAULT_MODEL } from "../model-logic"; |
| 8 | 15 | import { derivePendingApprovals } from "../session-logic"; |
| 9 | 16 | import { useStore } from "../store"; |
| 17 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | |
| 10 | 18 | import { |
| 11 | 19 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 12 | 20 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -15,9 +23,13 @@ import { |
| 15 | 23 | } from "../types"; |
| 16 | 24 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 17 | 25 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 26 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | |
| 27 | import { toastManager } from "./ui/toast"; | |
| 18 | 28 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 19 | 29 | |
| 20 | 30 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| 31 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | |
| 32 | ||
| 21 | 33 | function formatRelativeTime(iso: string): string { |
| 22 | 34 | const diff = Date.now() - new Date(iso).getTime(); |
| 23 | 35 | const minutes = Math.floor(diff / 60_000); |
| ... | ... | @@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string { |
| 36 | 48 | } |
| 37 | 49 | |
| 38 | 50 | interface ThreadStatusPill { |
| 39 | label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged"; | |
| 51 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; | |
| 40 | 52 | colorClass: string; |
| 41 | 53 | dotClass: string; |
| 42 | 54 | pulse: boolean; |
| ... | ... | @@ -48,6 +60,13 @@ interface TerminalStatusIndicator { |
| 48 | 60 | pulse: boolean; |
| 49 | 61 | } |
| 50 | 62 | |
| 63 | interface PrStatusIndicator { | |
| 64 | label: "PR open" | "PR closed" | "PR merged"; | |
| 65 | colorClass: string; | |
| 66 | } | |
| 67 | ||
| 68 | type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null; | |
| 69 | ||
| 51 | 70 | function hasUnseenCompletion(thread: Thread): boolean { |
| 52 | 71 | if (!thread.latestTurnCompletedAt) return false; |
| 53 | 72 | const completedAt = Date.parse(thread.latestTurnCompletedAt); |
| ... | ... | @@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean { |
| 62 | 81 | function threadStatusPill( |
| 63 | 82 | thread: Thread, |
| 64 | 83 | hasPendingApprovals: boolean, |
| 65 | hasMergedPr: boolean, | |
| 66 | 84 | ): ThreadStatusPill | null { |
| 67 | 85 | if (hasPendingApprovals) { |
| 68 | 86 | return { |
| ... | ... | @@ -91,15 +109,6 @@ function threadStatusPill( |
| 91 | 109 | }; |
| 92 | 110 | } |
| 93 | 111 | |
| 94 | if (hasMergedPr) { | |
| 95 | return { | |
| 96 | label: "Merged", | |
| 97 | colorClass: "text-violet-600 dark:text-violet-300/90", | |
| 98 | dotClass: "bg-violet-500 dark:bg-violet-300/90", | |
| 99 | pulse: false, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | 112 | if (hasUnseenCompletion(thread)) { |
| 104 | 113 | return { |
| 105 | 114 | label: "Completed", |
| ... | ... | @@ -112,6 +121,28 @@ function threadStatusPill( |
| 112 | 121 | return null; |
| 113 | 122 | } |
| 114 | 123 | |
| 124 | function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { | |
| 125 | if (prState === "open") { | |
| 126 | return { | |
| 127 | label: "PR open", | |
| 128 | colorClass: "text-emerald-600 dark:text-emerald-300/90", | |
| 129 | }; | |
| 130 | } | |
| 131 | if (prState === "closed") { | |
| 132 | return { | |
| 133 | label: "PR closed", | |
| 134 | colorClass: "text-zinc-500 dark:text-zinc-400/80", | |
| 135 | }; | |
| 136 | } | |
| 137 | if (prState === "merged") { | |
| 138 | return { | |
| 139 | label: "PR merged", | |
| 140 | colorClass: "text-violet-600 dark:text-violet-300/90", | |
| 141 | }; | |
| 142 | } | |
| 143 | return null; | |
| 144 | } | |
| 145 | ||
| 115 | 146 | function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { |
| 116 | 147 | if (thread.runningTerminalIds.length === 0) { |
| 117 | 148 | return null; |
| ... | ... | @@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null |
| 126 | 157 | export default function Sidebar() { |
| 127 | 158 | const { state, dispatch } = useStore(); |
| 128 | 159 | const api = useNativeApi(); |
| 160 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ | |
| 161 | ...serverConfigQueryOptions(api), | |
| 162 | select: (config) => config.keybindings, | |
| 163 | }); | |
| 129 | 164 | const queryClient = useQueryClient(); |
| 130 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient })); | |
| 165 | const removeWorktreeMutation = useMutation( | |
| 166 | gitRemoveWorktreeMutationOptions({ api, queryClient }), | |
| 167 | ); | |
| 131 | 168 | const { theme, setTheme } = useTheme(); |
| 132 | 169 | const [addingProject, setAddingProject] = useState(false); |
| 133 | 170 | const [newCwd, setNewCwd] = useState(""); |
| ... | ... | @@ -176,7 +213,7 @@ export default function Sidebar() { |
| 176 | 213 | }; |
| 177 | 214 | }), |
| 178 | 215 | }); |
| 179 | const mergedPrByThreadId = useMemo(() => { | |
| 216 | const prStateByThreadId = useMemo(() => { | |
| 180 | 217 | const statusByCwd = new Map<string, GitStatusResult>(); |
| 181 | 218 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { |
| 182 | 219 | const cwd = threadGitStatusCwds[index]; |
| ... | ... | @@ -187,18 +224,24 @@ export default function Sidebar() { |
| 187 | 224 | } |
| 188 | 225 | } |
| 189 | 226 | |
| 190 | const map = new Map<string, boolean>(); | |
| 227 | const map = new Map<string, ThreadPrState>(); | |
| 191 | 228 | for (const target of threadGitTargets) { |
| 192 | 229 | const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; |
| 193 | 230 | const branchMatches = |
| 194 | 231 | target.branch !== null && status?.branch !== null && status?.branch === target.branch; |
| 195 | map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null); | |
| 232 | map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null); | |
| 196 | 233 | } |
| 197 | 234 | return map; |
| 198 | 235 | }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); |
| 199 | 236 | |
| 200 | 237 | const handleNewThread = useCallback( |
| 201 | (projectId: string) => { | |
| 238 | ( | |
| 239 | projectId: string, | |
| 240 | options?: { | |
| 241 | branch?: string | null; | |
| 242 | worktreePath?: string | null; | |
| 243 | }, | |
| 244 | ) => { | |
| 202 | 245 | dispatch({ |
| 203 | 246 | type: "ADD_THREAD", |
| 204 | 247 | thread: { |
| ... | ... | @@ -224,8 +267,8 @@ export default function Sidebar() { |
| 224 | 267 | events: [], |
| 225 | 268 | error: null, |
| 226 | 269 | createdAt: new Date().toISOString(), |
| 227 | branch: null, | |
| 228 | worktreePath: null, | |
| 270 | branch: options?.branch ?? null, | |
| 271 | worktreePath: options?.worktreePath ?? null, | |
| 229 | 272 | }, |
| 230 | 273 | }); |
| 231 | 274 | }, |
| ... | ... | @@ -392,29 +435,77 @@ export default function Sidebar() { |
| 392 | 435 | [api, dispatch, removeWorktreeMutation, state.projects, state.threads], |
| 393 | 436 | ); |
| 394 | 437 | |
| 438 | const handleProjectContextMenu = useCallback( | |
| 439 | async (projectId: string, position: { x: number; y: number }) => { | |
| 440 | if (!api) return; | |
| 441 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | |
| 442 | if (clicked !== "delete") return; | |
| 443 | ||
| 444 | const project = state.projects.find((entry) => entry.id === projectId); | |
| 445 | if (!project) return; | |
| 446 | ||
| 447 | const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); | |
| 448 | if (projectThreads.length > 0) { | |
| 449 | toastManager.add({ | |
| 450 | type: "warning", | |
| 451 | title: "Project is not empty", | |
| 452 | description: "Delete all threads in this project before deleting it.", | |
| 453 | }); | |
| 454 | return; | |
| 455 | } | |
| 456 | ||
| 457 | const confirmed = await api.dialogs.confirm( | |
| 458 | [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), | |
| 459 | ); | |
| 460 | if (!confirmed) return; | |
| 461 | ||
| 462 | if (isElectron) { | |
| 463 | try { | |
| 464 | await api.projects.remove({ id: projectId }); | |
| 465 | } catch (error) { | |
| 466 | const message = | |
| 467 | error instanceof Error ? error.message : "Unknown error deleting project."; | |
| 468 | console.error("Failed to remove project", { projectId, error }); | |
| 469 | toastManager.add({ | |
| 470 | type: "error", | |
| 471 | title: `Failed to delete "${project.name}"`, | |
| 472 | description: message, | |
| 473 | }); | |
| 474 | return; | |
| 475 | } | |
| 476 | } | |
| 477 | ||
| 478 | dispatch({ type: "DELETE_PROJECT", projectId }); | |
| 479 | }, | |
| 480 | [api, dispatch, state.projects, state.threads], | |
| 481 | ); | |
| 482 | ||
| 395 | 483 | useEffect(() => { |
| 396 | 484 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 397 | const isNewThreadShortcut = | |
| 398 | event.metaKey && | |
| 399 | event.shiftKey && | |
| 400 | !event.ctrlKey && | |
| 401 | !event.altKey && | |
| 402 | event.key.toLowerCase() === "o"; | |
| 403 | if (!isNewThreadShortcut) return; | |
| 404 | ||
| 405 | 485 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 486 | if (isChatNewLocalShortcut(event, keybindings)) { | |
| 487 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | |
| 488 | if (!projectId) return; | |
| 489 | event.preventDefault(); | |
| 490 | handleNewThread(projectId); | |
| 491 | return; | |
| 492 | } | |
| 493 | ||
| 494 | if (!isChatNewShortcut(event, keybindings)) return; | |
| 406 | 495 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 407 | 496 | if (!projectId) return; |
| 408 | ||
| 409 | 497 | event.preventDefault(); |
| 410 | handleNewThread(projectId); | |
| 498 | handleNewThread(projectId, { | |
| 499 | branch: activeThread?.branch ?? null, | |
| 500 | worktreePath: activeThread?.worktreePath ?? null, | |
| 501 | }); | |
| 411 | 502 | }; |
| 412 | 503 | |
| 413 | 504 | window.addEventListener("keydown", onWindowKeyDown); |
| 414 | 505 | return () => { |
| 415 | 506 | window.removeEventListener("keydown", onWindowKeyDown); |
| 416 | 507 | }; |
| 417 | }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); | |
| 508 | }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); | |
| 418 | 509 | |
| 419 | 510 | return ( |
| 420 | 511 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> |
| ... | ... | @@ -478,6 +569,13 @@ export default function Sidebar() { |
| 478 | 569 | type="button" |
| 479 | 570 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" |
| 480 | 571 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} |
| 572 | onContextMenu={(e) => { | |
| 573 | e.preventDefault(); | |
| 574 | void handleProjectContextMenu(project.id, { | |
| 575 | x: e.clientX, | |
| 576 | y: e.clientY, | |
| 577 | }); | |
| 578 | }} | |
| 481 | 579 | > |
| 482 | 580 | <span className="text-[10px] text-muted-foreground/70"> |
| 483 | 581 | {project.expanded ? "â–¼" : "â–¶"} |
| ... | ... | @@ -496,9 +594,9 @@ export default function Sidebar() { |
| 496 | 594 | const threadStatus = threadStatusPill( |
| 497 | 595 | thread, |
| 498 | 596 | pendingApprovalByThreadId.get(thread.id) === true, |
| 499 | mergedPrByThreadId.get(thread.id) === true, | |
| 500 | 597 | ); |
| 501 | 598 | const terminalStatus = terminalStatusIndicator(thread); |
| 599 | const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); | |
| 502 | 600 | return ( |
| 503 | 601 | <button |
| 504 | 602 | key={thread.id} |
| ... | ... | @@ -538,6 +636,16 @@ export default function Sidebar() { |
| 538 | 636 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> |
| 539 | 637 | </div> |
| 540 | 638 | <div className="ml-2 flex shrink-0 items-center gap-1.5"> |
| 639 | {prStatus && ( | |
| 640 | <span | |
| 641 | role="img" | |
| 642 | aria-label={prStatus.label} | |
| 643 | title={prStatus.label} | |
| 644 | className={`inline-flex items-center justify-center ${prStatus.colorClass}`} | |
| 645 | > | |
| 646 | <GitPullRequestIcon className="size-3" /> | |
| 647 | </span> | |
| 648 | )} | |
| 541 | 649 | {terminalStatus && ( |
| 542 | 650 | <span |
| 543 | 651 | role="img" |
apps/web/src/components/ThreadTerminalDrawer.tsx+65-36| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; | |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; | |
| 3 | 3 | import { type NativeApi } from "@t3tools/contracts"; |
| 4 | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 5 | 5 | import { |
| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | preferredTerminalEditor, |
| 19 | 19 | resolvePathLinkTarget, |
| 20 | 20 | } from "../terminal-links"; |
| 21 | import { isTerminalClearShortcut } from "../terminal-shortcuts"; | |
| 21 | import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; | |
| 22 | 22 | import { |
| 23 | 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | 24 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -152,24 +152,29 @@ function TerminalViewport({ |
| 152 | 152 | terminalRef.current = terminal; |
| 153 | 153 | fitAddonRef.current = fitAddon; |
| 154 | 154 | |
| 155 | const sendClearShortcut = async () => { | |
| 155 | const sendTerminalInput = async (data: string, fallbackError: string) => { | |
| 156 | 156 | const activeTerminal = terminalRef.current; |
| 157 | 157 | if (!activeTerminal) return; |
| 158 | 158 | try { |
| 159 | await api.terminal.write({ threadId, terminalId, data: "\u000c" }); | |
| 159 | await api.terminal.write({ threadId, terminalId, data }); | |
| 160 | 160 | } catch (error) { |
| 161 | writeSystemMessage( | |
| 162 | activeTerminal, | |
| 163 | error instanceof Error ? error.message : "Failed to clear terminal", | |
| 164 | ); | |
| 161 | writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); | |
| 165 | 162 | } |
| 166 | 163 | }; |
| 167 | 164 | |
| 168 | 165 | terminal.attachCustomKeyEventHandler((event) => { |
| 166 | const navigationData = terminalNavigationShortcutData(event); | |
| 167 | if (navigationData !== null) { | |
| 168 | event.preventDefault(); | |
| 169 | event.stopPropagation(); | |
| 170 | void sendTerminalInput(navigationData, "Failed to move cursor"); | |
| 171 | return false; | |
| 172 | } | |
| 173 | ||
| 169 | 174 | if (!isTerminalClearShortcut(event)) return true; |
| 170 | 175 | event.preventDefault(); |
| 171 | 176 | event.stopPropagation(); |
| 172 | void sendClearShortcut(); | |
| 177 | void sendTerminalInput("\u000c", "Failed to clear terminal"); | |
| 173 | 178 | return false; |
| 174 | 179 | }); |
| 175 | 180 | |
| ... | ... | @@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps { |
| 416 | 421 | focusRequestId: number; |
| 417 | 422 | onSplitTerminal: () => void; |
| 418 | 423 | onNewTerminal: () => void; |
| 424 | splitShortcutLabel?: string | undefined; | |
| 425 | newShortcutLabel?: string | undefined; | |
| 426 | closeShortcutLabel?: string | undefined; | |
| 419 | 427 | onActiveTerminalChange: (terminalId: string) => void; |
| 420 | 428 | onCloseTerminal: (terminalId: string) => void; |
| 421 | 429 | onHeightChange: (height: number) => void; |
| ... | ... | @@ -431,15 +439,9 @@ interface TerminalActionButtonProps { |
| 431 | 439 | function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { |
| 432 | 440 | return ( |
| 433 | 441 | <Popover> |
| 434 | <PopoverTrigger openOnHover | |
| 435 | render={ | |
| 436 | <button | |
| 437 | type="button" | |
| 438 | className={className} | |
| 439 | onClick={onClick} | |
| 440 | aria-label={label} | |
| 441 | /> | |
| 442 | } | |
| 442 | <PopoverTrigger | |
| 443 | openOnHover | |
| 444 | render={<button type="button" className={className} onClick={onClick} aria-label={label} />} | |
| 443 | 445 | > |
| 444 | 446 | {children} |
| 445 | 447 | </PopoverTrigger> |
| ... | ... | @@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({ |
| 468 | 470 | focusRequestId, |
| 469 | 471 | onSplitTerminal, |
| 470 | 472 | onNewTerminal, |
| 473 | splitShortcutLabel, | |
| 474 | newShortcutLabel, | |
| 475 | closeShortcutLabel, | |
| 471 | 476 | onActiveTerminalChange, |
| 472 | 477 | onCloseTerminal, |
| 473 | 478 | onHeightChange, |
| ... | ... | @@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({ |
| 583 | 588 | ), |
| 584 | 589 | [normalizedTerminalIds], |
| 585 | 590 | ); |
| 591 | const closeTerminalActionLabel = closeShortcutLabel | |
| 592 | ? `Close Terminal (${closeShortcutLabel})` | |
| 593 | : "Close Terminal"; | |
| 586 | 594 | |
| 587 | 595 | useEffect(() => { |
| 588 | 596 | onHeightChangeRef.current = onHeightChange; |
| ... | ... | @@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({ |
| 694 | 702 | <TerminalActionButton |
| 695 | 703 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 696 | 704 | onClick={onSplitTerminal} |
| 697 | label="Split Terminal" | |
| 705 | label={ | |
| 706 | splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal" | |
| 707 | } | |
| 698 | 708 | > |
| 699 | 709 | <SquareSplitHorizontal className="size-3.25" /> |
| 700 | 710 | </TerminalActionButton> |
| ... | ... | @@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({ |
| 702 | 712 | <TerminalActionButton |
| 703 | 713 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 704 | 714 | onClick={onNewTerminal} |
| 705 | label="New Terminal" | |
| 715 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} | |
| 706 | 716 | > |
| 707 | 717 | <Plus className="size-3.25" /> |
| 708 | 718 | </TerminalActionButton> |
| ... | ... | @@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({ |
| 710 | 720 | <TerminalActionButton |
| 711 | 721 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 712 | 722 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 713 | label="Close Terminal" | |
| 723 | label={closeTerminalActionLabel} | |
| 714 | 724 | > |
| 715 | 725 | <Trash2 className="size-3.25" /> |
| 716 | 726 | </TerminalActionButton> |
| ... | ... | @@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({ |
| 779 | 789 | <TerminalActionButton |
| 780 | 790 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 781 | 791 | onClick={onSplitTerminal} |
| 782 | label="Split Terminal" | |
| 792 | label={ | |
| 793 | splitShortcutLabel | |
| 794 | ? `Split Terminal (${splitShortcutLabel})` | |
| 795 | : "Split Terminal" | |
| 796 | } | |
| 783 | 797 | > |
| 784 | 798 | <SquareSplitHorizontal className="size-3.25" /> |
| 785 | 799 | </TerminalActionButton> |
| 786 | 800 | <TerminalActionButton |
| 787 | 801 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 788 | 802 | onClick={onNewTerminal} |
| 789 | label="New Terminal" | |
| 803 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} | |
| 790 | 804 | > |
| 791 | 805 | <Plus className="size-3.25" /> |
| 792 | 806 | </TerminalActionButton> |
| 793 | 807 | <TerminalActionButton |
| 794 | 808 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 795 | 809 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 796 | label="Close Terminal" | |
| 810 | label={closeTerminalActionLabel} | |
| 797 | 811 | > |
| 798 | 812 | <Trash2 className="size-3.25" /> |
| 799 | 813 | </TerminalActionButton> |
| ... | ... | @@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({ |
| 831 | 845 | > |
| 832 | 846 | {terminalGroup.terminalIds.map((terminalId) => { |
| 833 | 847 | const isActive = terminalId === resolvedActiveTerminalId; |
| 848 | const closeTerminalLabel = `Close ${ | |
| 849 | terminalLabelById.get(terminalId) ?? "terminal" | |
| 850 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; | |
| 834 | 851 | return ( |
| 835 | 852 | <div |
| 836 | 853 | key={terminalId} |
| ... | ... | @@ -854,18 +871,30 @@ export default function ThreadTerminalDrawer({ |
| 854 | 871 | </span> |
| 855 | 872 | </button> |
| 856 | 873 | {normalizedTerminalIds.length > 1 && ( |
| 857 | <button | |
| 858 | type="button" | |
| 859 | className="rounded px-1 text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | |
| 860 | onClick={(event) => { | |
| 861 | event.stopPropagation(); | |
| 862 | onCloseTerminal(terminalId); | |
| 863 | }} | |
| 864 | aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | |
| 865 | title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | |
| 866 | > | |
| 867 | × | |
| 868 | </button> | |
| 874 | <Popover> | |
| 875 | <PopoverTrigger | |
| 876 | openOnHover | |
| 877 | render={ | |
| 878 | <button | |
| 879 | type="button" | |
| 880 | className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | |
| 881 | onClick={() => onCloseTerminal(terminalId)} | |
| 882 | aria-label={closeTerminalLabel} | |
| 883 | /> | |
| 884 | } | |
| 885 | > | |
| 886 | <XIcon className="size-2.5" /> | |
| 887 | </PopoverTrigger> | |
| 888 | <PopoverPopup | |
| 889 | tooltipStyle | |
| 890 | side="bottom" | |
| 891 | sideOffset={6} | |
| 892 | align="center" | |
| 893 | className="pointer-events-none select-none" | |
| 894 | > | |
| 895 | {closeTerminalLabel} | |
| 896 | </PopoverPopup> | |
| 897 | </Popover> | |
| 869 | 898 | )} |
| 870 | 899 | </div> |
| 871 | 900 | ); |
apps/web/src/components/ui/toast.logic.test.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 2 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 3 | ||
| 4 | describe("shouldHideCollapsedToastContent", () => { | |
| 5 | it("keeps a single visible toast readable", () => { | |
| 6 | assert.equal(shouldHideCollapsedToastContent(0, 1), false); | |
| 7 | }); | |
| 8 | ||
| 9 | it("keeps the front-most toast readable in a visible stack", () => { | |
| 10 | assert.equal(shouldHideCollapsedToastContent(0, 3), false); | |
| 11 | }); | |
| 12 | ||
| 13 | it("hides non-front toasts until the stack is expanded", () => { | |
| 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); | |
| 15 | }); | |
| 16 | }); |
apps/web/src/components/ui/toast.logic.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | export function shouldHideCollapsedToastContent( | |
| 2 | visibleToastIndex: number, | |
| 3 | visibleToastCount: number, | |
| 4 | ): boolean { | |
| 5 | // Keep the front-most toast readable even if Base UI marks it as "behind" | |
| 6 | // due to toasts hidden by thread filtering. | |
| 7 | if (visibleToastCount <= 1) return false; | |
| 8 | return visibleToastIndex > 0; | |
| 9 | } |
apps/web/src/components/ui/toast.tsx+190-84| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | |
| 4 | 5 | import { |
| 5 | 6 | CircleAlertIcon, |
| 6 | 7 | CircleCheckIcon, |
| ... | ... | @@ -12,14 +13,18 @@ import { |
| 12 | 13 | import { cn } from "~/lib/utils"; |
| 13 | 14 | import { buttonVariants } from "~/components/ui/button"; |
| 14 | 15 | import { useStore } from "~/store"; |
| 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 15 | 17 | |
| 16 | 18 | type ThreadToastData = { |
| 17 | 19 | threadId?: string | null; |
| 18 | 20 | tooltipStyle?: boolean; |
| 21 | dismissAfterVisibleMs?: number; | |
| 19 | 22 | }; |
| 20 | 23 | |
| 21 | 24 | const toastManager = Toast.createToastManager<ThreadToastData>(); |
| 22 | 25 | const anchoredToastManager = Toast.createToastManager<ThreadToastData>(); |
| 26 | type ToastId = ReturnType<typeof toastManager.add>; | |
| 27 | const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>(); | |
| 23 | 28 | |
| 24 | 29 | const TOAST_ICONS = { |
| 25 | 30 | error: CircleAlertIcon, |
| ... | ... | @@ -50,6 +55,84 @@ function shouldRenderForActiveThread( |
| 50 | 55 | return toastThreadId === activeThreadId; |
| 51 | 56 | } |
| 52 | 57 | |
| 58 | function ThreadToastVisibleAutoDismiss({ | |
| 59 | toastId, | |
| 60 | dismissAfterVisibleMs, | |
| 61 | }: { | |
| 62 | toastId: ToastId; | |
| 63 | dismissAfterVisibleMs: number | undefined; | |
| 64 | }) { | |
| 65 | useEffect(() => { | |
| 66 | if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; | |
| 67 | if (typeof window === "undefined" || typeof document === "undefined") return; | |
| 68 | ||
| 69 | let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; | |
| 70 | let startedAtMs: number | null = null; | |
| 71 | let timeoutId: number | null = null; | |
| 72 | let closed = false; | |
| 73 | ||
| 74 | const clearTimer = () => { | |
| 75 | if (timeoutId === null) return; | |
| 76 | window.clearTimeout(timeoutId); | |
| 77 | timeoutId = null; | |
| 78 | }; | |
| 79 | ||
| 80 | const closeToast = () => { | |
| 81 | if (closed) return; | |
| 82 | closed = true; | |
| 83 | threadToastVisibleTimeoutRemainingMs.delete(toastId); | |
| 84 | toastManager.close(toastId); | |
| 85 | }; | |
| 86 | ||
| 87 | const pause = () => { | |
| 88 | if (startedAtMs === null) return; | |
| 89 | remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); | |
| 90 | startedAtMs = null; | |
| 91 | clearTimer(); | |
| 92 | threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); | |
| 93 | }; | |
| 94 | ||
| 95 | const start = () => { | |
| 96 | if (closed || startedAtMs !== null) return; | |
| 97 | if (remainingMs <= 0) { | |
| 98 | closeToast(); | |
| 99 | return; | |
| 100 | } | |
| 101 | startedAtMs = Date.now(); | |
| 102 | clearTimer(); | |
| 103 | timeoutId = window.setTimeout(() => { | |
| 104 | remainingMs = 0; | |
| 105 | startedAtMs = null; | |
| 106 | closeToast(); | |
| 107 | }, remainingMs); | |
| 108 | }; | |
| 109 | ||
| 110 | const syncTimer = () => { | |
| 111 | const shouldRun = document.visibilityState === "visible" && document.hasFocus(); | |
| 112 | if (shouldRun) { | |
| 113 | start(); | |
| 114 | return; | |
| 115 | } | |
| 116 | pause(); | |
| 117 | }; | |
| 118 | ||
| 119 | syncTimer(); | |
| 120 | document.addEventListener("visibilitychange", syncTimer); | |
| 121 | window.addEventListener("focus", syncTimer); | |
| 122 | window.addEventListener("blur", syncTimer); | |
| 123 | ||
| 124 | return () => { | |
| 125 | document.removeEventListener("visibilitychange", syncTimer); | |
| 126 | window.removeEventListener("focus", syncTimer); | |
| 127 | window.removeEventListener("blur", syncTimer); | |
| 128 | pause(); | |
| 129 | clearTimer(); | |
| 130 | }; | |
| 131 | }, [dismissAfterVisibleMs, toastId]); | |
| 132 | ||
| 133 | return null; | |
| 134 | } | |
| 135 | ||
| 53 | 136 | function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { |
| 54 | 137 | return ( |
| 55 | 138 | <Toast.Provider toastManager={toastManager} {...props}> |
| ... | ... | @@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 64 | 147 | const { toasts } = Toast.useToastManager<ThreadToastData>(); |
| 65 | 148 | const activeThreadId = state.activeThreadId; |
| 66 | 149 | const isTop = position.startsWith("top"); |
| 150 | const visibleToasts = toasts.filter((toast) => | |
| 151 | shouldRenderForActiveThread(toast.data, activeThreadId), | |
| 152 | ); | |
| 153 | ||
| 154 | useEffect(() => { | |
| 155 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); | |
| 156 | for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { | |
| 157 | if (!activeToastIds.has(toastId)) { | |
| 158 | threadToastVisibleTimeoutRemainingMs.delete(toastId); | |
| 159 | } | |
| 160 | } | |
| 161 | }, [toasts]); | |
| 67 | 162 | |
| 68 | 163 | return ( |
| 69 | 164 | <Toast.Portal data-slot="toast-portal"> |
| ... | ... | @@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 81 | 176 | data-position={position} |
| 82 | 177 | data-slot="toast-viewport" |
| 83 | 178 | > |
| 84 | {toasts | |
| 85 | .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId)) | |
| 86 | .map((toast) => { | |
| 87 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | |
| 179 | {visibleToasts.map((toast, visibleIndex) => { | |
| 180 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | |
| 181 | const hideCollapsedContent = shouldHideCollapsedToastContent( | |
| 182 | visibleIndex, | |
| 183 | visibleToasts.length, | |
| 184 | ); | |
| 88 | 185 | |
| 89 | return ( | |
| 90 | <Toast.Root | |
| 186 | return ( | |
| 187 | <Toast.Root | |
| 188 | className={cn( | |
| 189 | "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | |
| 190 | // Base positioning using data-position | |
| 191 | "data-[position*=right]:right-0 data-[position*=right]:left-auto", | |
| 192 | "data-[position*=left]:right-auto data-[position*=left]:left-0", | |
| 193 | "data-[position*=center]:right-0 data-[position*=center]:left-0", | |
| 194 | "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top", | |
| 195 | "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom", | |
| 196 | // Gap fill for hover | |
| 197 | "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full", | |
| 198 | "data-[position*=top]:after:top-full", | |
| 199 | "data-[position*=bottom]:after:bottom-full", | |
| 200 | // Define some variables | |
| 201 | "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]", | |
| 202 | // Define offset-y variable | |
| 203 | "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]", | |
| 204 | "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]", | |
| 205 | // Default state transform | |
| 206 | "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | |
| 207 | "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | |
| 208 | // Limited state | |
| 209 | "data-limited:opacity-0", | |
| 210 | // Expanded state | |
| 211 | "data-expanded:h-(--toast-height)", | |
| 212 | "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", | |
| 213 | // Starting and ending animations | |
| 214 | "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", | |
| 215 | "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]", | |
| 216 | "data-[position*=top]:data-[position*=right]:data-starting-style:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 217 | "data-ending-style:opacity-0", | |
| 218 | // Ending animations (direction-aware) | |
| 219 | "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]", | |
| 220 | "data-[position*=top]:data-[position*=right]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 221 | "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 222 | "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 223 | "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | |
| 224 | "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | |
| 225 | // Ending animations (expanded) | |
| 226 | "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 227 | "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 228 | "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | |
| 229 | "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | |
| 230 | )} | |
| 231 | data-position={position} | |
| 232 | key={toast.id} | |
| 233 | swipeDirection={ | |
| 234 | position.includes("center") | |
| 235 | ? [isTop ? "up" : "down"] | |
| 236 | : position.includes("left") | |
| 237 | ? ["left", isTop ? "up" : "down"] | |
| 238 | : ["right", isTop ? "up" : "down"] | |
| 239 | } | |
| 240 | toast={toast} | |
| 241 | > | |
| 242 | <ThreadToastVisibleAutoDismiss | |
| 243 | dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} | |
| 244 | toastId={toast.id} | |
| 245 | /> | |
| 246 | <Toast.Content | |
| 91 | 247 | className={cn( |
| 92 | "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | |
| 93 | // Base positioning using data-position | |
| 94 | "data-[position*=right]:right-0 data-[position*=right]:left-auto", | |
| 95 | "data-[position*=left]:right-auto data-[position*=left]:left-0", | |
| 96 | "data-[position*=center]:right-0 data-[position*=center]:left-0", | |
| 97 | "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top", | |
| 98 | "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom", | |
| 99 | // Gap fill for hover | |
| 100 | "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full", | |
| 101 | "data-[position*=top]:after:top-full", | |
| 102 | "data-[position*=bottom]:after:bottom-full", | |
| 103 | // Define some variables | |
| 104 | "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]", | |
| 105 | // Define offset-y variable | |
| 106 | "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]", | |
| 107 | "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]", | |
| 108 | // Default state transform | |
| 109 | "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | |
| 110 | "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | |
| 111 | // Limited state | |
| 112 | "data-limited:opacity-0", | |
| 113 | // Expanded state | |
| 114 | "data-expanded:h-(--toast-height)", | |
| 115 | "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", | |
| 116 | // Starting and ending animations | |
| 117 | "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", | |
| 118 | "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]", | |
| 119 | "data-ending-style:opacity-0", | |
| 120 | // Ending animations (direction-aware) | |
| 121 | "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]", | |
| 122 | "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 123 | "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 124 | "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | |
| 125 | "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | |
| 126 | // Ending animations (expanded) | |
| 127 | "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 128 | "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | |
| 129 | "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | |
| 130 | "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | |
| 248 | "pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100", | |
| 249 | hideCollapsedContent && | |
| 250 | "not-data-expanded:pointer-events-none not-data-expanded:opacity-0", | |
| 131 | 251 | )} |
| 132 | data-position={position} | |
| 133 | key={toast.id} | |
| 134 | swipeDirection={ | |
| 135 | position.includes("center") | |
| 136 | ? [isTop ? "up" : "down"] | |
| 137 | : position.includes("left") | |
| 138 | ? ["left", isTop ? "up" : "down"] | |
| 139 | : ["right", isTop ? "up" : "down"] | |
| 140 | } | |
| 141 | toast={toast} | |
| 142 | 252 | > |
| 143 | <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100"> | |
| 144 | <div className="flex gap-2"> | |
| 145 | {Icon && ( | |
| 146 | <div | |
| 147 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" | |
| 148 | data-slot="toast-icon" | |
| 149 | > | |
| 150 | <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" /> | |
| 151 | </div> | |
| 152 | )} | |
| 153 | ||
| 154 | <div className="flex flex-col gap-0.5"> | |
| 155 | <Toast.Title className="font-medium" data-slot="toast-title" /> | |
| 156 | <Toast.Description | |
| 157 | className="text-muted-foreground" | |
| 158 | data-slot="toast-description" | |
| 159 | /> | |
| 160 | </div> | |
| 161 | </div> | |
| 162 | {toast.actionProps && ( | |
| 163 | <Toast.Action | |
| 164 | className={buttonVariants({ size: "xs" })} | |
| 165 | data-slot="toast-action" | |
| 253 | <div className="flex gap-2"> | |
| 254 | {Icon && ( | |
| 255 | <div | |
| 256 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" | |
| 257 | data-slot="toast-icon" | |
| 166 | 258 | > |
| 167 | {toast.actionProps.children} | |
| 168 | </Toast.Action> | |
| 259 | <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" /> | |
| 260 | </div> | |
| 169 | 261 | )} |
| 170 | </Toast.Content> | |
| 171 | </Toast.Root> | |
| 172 | ); | |
| 173 | })} | |
| 262 | ||
| 263 | <div className="flex flex-col gap-0.5"> | |
| 264 | <Toast.Title className="font-medium" data-slot="toast-title" /> | |
| 265 | <Toast.Description | |
| 266 | className="text-muted-foreground" | |
| 267 | data-slot="toast-description" | |
| 268 | /> | |
| 269 | </div> | |
| 270 | </div> | |
| 271 | {toast.actionProps && ( | |
| 272 | <Toast.Action className={buttonVariants({ size: "xs" })} data-slot="toast-action"> | |
| 273 | {toast.actionProps.children} | |
| 274 | </Toast.Action> | |
| 275 | )} | |
| 276 | </Toast.Content> | |
| 277 | </Toast.Root> | |
| 278 | ); | |
| 279 | })} | |
| 174 | 280 | </Toast.Viewport> |
| 175 | 281 | </Toast.Portal> |
| 176 | 282 | ); |
apps/web/src/keybindings.test.ts created+444| ... | ... | @@ -0,0 +1,444 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | type KeybindingCommand, | |
| 5 | type KeybindingShortcut, | |
| 6 | type KeybindingWhenNode, | |
| 7 | type ResolvedKeybindingsConfig, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | formatShortcutLabel, | |
| 11 | isChatNewShortcut, | |
| 12 | isChatNewLocalShortcut, | |
| 13 | isOpenFavoriteEditorShortcut, | |
| 14 | isTerminalClearShortcut, | |
| 15 | isTerminalCloseShortcut, | |
| 16 | isTerminalNewShortcut, | |
| 17 | isTerminalSplitShortcut, | |
| 18 | isTerminalToggleShortcut, | |
| 19 | shortcutLabelForCommand, | |
| 20 | terminalNavigationShortcutData, | |
| 21 | type ShortcutEventLike, | |
| 22 | } from "./keybindings"; | |
| 23 | ||
| 24 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | |
| 25 | return { | |
| 26 | key: "j", | |
| 27 | metaKey: false, | |
| 28 | ctrlKey: false, | |
| 29 | shiftKey: false, | |
| 30 | altKey: false, | |
| 31 | ...overrides, | |
| 32 | }; | |
| 33 | } | |
| 34 | ||
| 35 | function modShortcut( | |
| 36 | key: string, | |
| 37 | overrides: Partial<Omit<KeybindingShortcut, "key">> = {}, | |
| 38 | ): KeybindingShortcut { | |
| 39 | return { | |
| 40 | key, | |
| 41 | metaKey: false, | |
| 42 | ctrlKey: false, | |
| 43 | shiftKey: false, | |
| 44 | altKey: false, | |
| 45 | modKey: true, | |
| 46 | ...overrides, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | function whenIdentifier(name: string): KeybindingWhenNode { | |
| 51 | return { type: "identifier", name }; | |
| 52 | } | |
| 53 | ||
| 54 | function whenNot(node: KeybindingWhenNode): KeybindingWhenNode { | |
| 55 | return { type: "not", node }; | |
| 56 | } | |
| 57 | ||
| 58 | function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode { | |
| 59 | return { type: "and", left, right }; | |
| 60 | } | |
| 61 | ||
| 62 | interface TestBinding { | |
| 63 | shortcut: KeybindingShortcut; | |
| 64 | command: KeybindingCommand; | |
| 65 | whenAst?: KeybindingWhenNode; | |
| 66 | } | |
| 67 | ||
| 68 | function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { | |
| 69 | return bindings.map((binding) => ({ | |
| 70 | command: binding.command, | |
| 71 | shortcut: binding.shortcut, | |
| 72 | ...(binding.whenAst ? { whenAst: binding.whenAst } : {}), | |
| 73 | })); | |
| 74 | } | |
| 75 | ||
| 76 | const DEFAULT_BINDINGS = compile([ | |
| 77 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, | |
| 78 | { | |
| 79 | shortcut: modShortcut("d"), | |
| 80 | command: "terminal.split", | |
| 81 | whenAst: whenIdentifier("terminalFocus"), | |
| 82 | }, | |
| 83 | { | |
| 84 | shortcut: modShortcut("d", { shiftKey: true }), | |
| 85 | command: "terminal.new", | |
| 86 | whenAst: whenIdentifier("terminalFocus"), | |
| 87 | }, | |
| 88 | { | |
| 89 | shortcut: modShortcut("w"), | |
| 90 | command: "terminal.close", | |
| 91 | whenAst: whenIdentifier("terminalFocus"), | |
| 92 | }, | |
| 93 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, | |
| 94 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, | |
| 95 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, | |
| 96 | ]); | |
| 97 | ||
| 98 | describe("isTerminalToggleShortcut", () => { | |
| 99 | it("matches Cmd+J on macOS", () => { | |
| 100 | assert.isTrue( | |
| 101 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }), | |
| 102 | ); | |
| 103 | }); | |
| 104 | ||
| 105 | it("matches Ctrl+J on non-macOS", () => { | |
| 106 | assert.isTrue( | |
| 107 | isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }), | |
| 108 | ); | |
| 109 | }); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("split/new/close terminal shortcuts", () => { | |
| 113 | it("requires terminalFocus for default split/new/close bindings", () => { | |
| 114 | assert.isFalse( | |
| 115 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | |
| 116 | platform: "MacIntel", | |
| 117 | context: { terminalFocus: false }, | |
| 118 | }), | |
| 119 | ); | |
| 120 | assert.isFalse( | |
| 121 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 122 | platform: "Linux", | |
| 123 | context: { terminalFocus: false }, | |
| 124 | }), | |
| 125 | ); | |
| 126 | assert.isFalse( | |
| 127 | isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 128 | platform: "Linux", | |
| 129 | context: { terminalFocus: false }, | |
| 130 | }), | |
| 131 | ); | |
| 132 | }); | |
| 133 | ||
| 134 | it("matches split/new when terminalFocus is true", () => { | |
| 135 | assert.isTrue( | |
| 136 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | |
| 137 | platform: "MacIntel", | |
| 138 | context: { terminalFocus: true }, | |
| 139 | }), | |
| 140 | ); | |
| 141 | assert.isTrue( | |
| 142 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 143 | platform: "Linux", | |
| 144 | context: { terminalFocus: true }, | |
| 145 | }), | |
| 146 | ); | |
| 147 | assert.isTrue( | |
| 148 | isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 149 | platform: "Linux", | |
| 150 | context: { terminalFocus: true }, | |
| 151 | }), | |
| 152 | ); | |
| 153 | }); | |
| 154 | ||
| 155 | it("supports when expressions", () => { | |
| 156 | const keybindings = compile([ | |
| 157 | { | |
| 158 | shortcut: modShortcut("\\"), | |
| 159 | command: "terminal.split", | |
| 160 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), | |
| 161 | }, | |
| 162 | { | |
| 163 | shortcut: modShortcut("n", { shiftKey: true }), | |
| 164 | command: "terminal.new", | |
| 165 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), | |
| 166 | }, | |
| 167 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, | |
| 168 | ]); | |
| 169 | assert.isTrue( | |
| 170 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | |
| 171 | platform: "Win32", | |
| 172 | context: { terminalOpen: true, terminalFocus: false }, | |
| 173 | }), | |
| 174 | ); | |
| 175 | assert.isFalse( | |
| 176 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | |
| 177 | platform: "Win32", | |
| 178 | context: { terminalOpen: false, terminalFocus: false }, | |
| 179 | }), | |
| 180 | ); | |
| 181 | assert.isTrue( | |
| 182 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { | |
| 183 | platform: "Win32", | |
| 184 | context: { terminalOpen: true, terminalFocus: false }, | |
| 185 | }), | |
| 186 | ); | |
| 187 | }); | |
| 188 | ||
| 189 | it("supports when boolean literals", () => { | |
| 190 | const keybindings = compile([ | |
| 191 | { shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("true") }, | |
| 192 | { shortcut: modShortcut("m"), command: "terminal.new", whenAst: whenIdentifier("false") }, | |
| 193 | ]); | |
| 194 | ||
| 195 | assert.isTrue( | |
| 196 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { | |
| 197 | platform: "Linux", | |
| 198 | }), | |
| 199 | ); | |
| 200 | assert.isFalse( | |
| 201 | isTerminalNewShortcut(event({ key: "m", ctrlKey: true }), keybindings, { | |
| 202 | platform: "Linux", | |
| 203 | }), | |
| 204 | ); | |
| 205 | }); | |
| 206 | }); | |
| 207 | ||
| 208 | describe("shortcutLabelForCommand", () => { | |
| 209 | it("returns the most recent binding label", () => { | |
| 210 | const bindings = compile([ | |
| 211 | { | |
| 212 | shortcut: modShortcut("\\"), | |
| 213 | command: "terminal.split", | |
| 214 | whenAst: whenIdentifier("terminalFocus"), | |
| 215 | }, | |
| 216 | { | |
| 217 | shortcut: modShortcut("\\", { shiftKey: true }), | |
| 218 | command: "terminal.split", | |
| 219 | whenAst: whenNot(whenIdentifier("terminalFocus")), | |
| 220 | }, | |
| 221 | ]); | |
| 222 | assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); | |
| 223 | }); | |
| 224 | ||
| 225 | it("returns labels for non-terminal commands", () => { | |
| 226 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); | |
| 227 | assert.strictEqual( | |
| 228 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), | |
| 229 | "Ctrl+O", | |
| 230 | ); | |
| 231 | }); | |
| 232 | }); | |
| 233 | ||
| 234 | describe("chat/editor shortcuts", () => { | |
| 235 | it("matches chat.new shortcut", () => { | |
| 236 | assert.isTrue( | |
| 237 | isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 238 | platform: "MacIntel", | |
| 239 | }), | |
| 240 | ); | |
| 241 | assert.isTrue( | |
| 242 | isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 243 | platform: "Linux", | |
| 244 | }), | |
| 245 | ); | |
| 246 | }); | |
| 247 | ||
| 248 | it("matches chat.newLocal shortcut", () => { | |
| 249 | assert.isTrue( | |
| 250 | isChatNewLocalShortcut( | |
| 251 | event({ key: "n", metaKey: true, shiftKey: true }), | |
| 252 | DEFAULT_BINDINGS, | |
| 253 | { | |
| 254 | platform: "MacIntel", | |
| 255 | }, | |
| 256 | ), | |
| 257 | ); | |
| 258 | assert.isTrue( | |
| 259 | isChatNewLocalShortcut( | |
| 260 | event({ key: "n", ctrlKey: true, shiftKey: true }), | |
| 261 | DEFAULT_BINDINGS, | |
| 262 | { | |
| 263 | platform: "Linux", | |
| 264 | }, | |
| 265 | ), | |
| 266 | ); | |
| 267 | }); | |
| 268 | ||
| 269 | it("matches editor.openFavorite shortcut", () => { | |
| 270 | assert.isTrue( | |
| 271 | isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { | |
| 272 | platform: "MacIntel", | |
| 273 | }), | |
| 274 | ); | |
| 275 | assert.isTrue( | |
| 276 | isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 277 | platform: "Linux", | |
| 278 | }), | |
| 279 | ); | |
| 280 | }); | |
| 281 | }); | |
| 282 | ||
| 283 | describe("cross-command precedence", () => { | |
| 284 | it("uses when + order so a later focused rule overrides a global rule", () => { | |
| 285 | const keybindings = compile([ | |
| 286 | { shortcut: modShortcut("n"), command: "chat.new" }, | |
| 287 | { | |
| 288 | shortcut: modShortcut("n"), | |
| 289 | command: "terminal.new", | |
| 290 | whenAst: whenIdentifier("terminalFocus"), | |
| 291 | }, | |
| 292 | ]); | |
| 293 | ||
| 294 | assert.isTrue( | |
| 295 | isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | |
| 296 | platform: "MacIntel", | |
| 297 | context: { terminalFocus: true }, | |
| 298 | }), | |
| 299 | ); | |
| 300 | assert.isFalse( | |
| 301 | isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | |
| 302 | platform: "MacIntel", | |
| 303 | context: { terminalFocus: true }, | |
| 304 | }), | |
| 305 | ); | |
| 306 | assert.isFalse( | |
| 307 | isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | |
| 308 | platform: "MacIntel", | |
| 309 | context: { terminalFocus: false }, | |
| 310 | }), | |
| 311 | ); | |
| 312 | assert.isTrue( | |
| 313 | isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | |
| 314 | platform: "MacIntel", | |
| 315 | context: { terminalFocus: false }, | |
| 316 | }), | |
| 317 | ); | |
| 318 | }); | |
| 319 | ||
| 320 | it("still lets a later global rule win when both rules match", () => { | |
| 321 | const keybindings = compile([ | |
| 322 | { | |
| 323 | shortcut: modShortcut("n"), | |
| 324 | command: "terminal.new", | |
| 325 | whenAst: whenIdentifier("terminalFocus"), | |
| 326 | }, | |
| 327 | { shortcut: modShortcut("n"), command: "chat.new" }, | |
| 328 | ]); | |
| 329 | ||
| 330 | assert.isFalse( | |
| 331 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { | |
| 332 | platform: "Linux", | |
| 333 | context: { terminalFocus: true }, | |
| 334 | }), | |
| 335 | ); | |
| 336 | assert.isTrue( | |
| 337 | isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { | |
| 338 | platform: "Linux", | |
| 339 | context: { terminalFocus: true }, | |
| 340 | }), | |
| 341 | ); | |
| 342 | }); | |
| 343 | }); | |
| 344 | ||
| 345 | describe("formatShortcutLabel", () => { | |
| 346 | it("formats labels for macOS", () => { | |
| 347 | assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D"); | |
| 348 | }); | |
| 349 | ||
| 350 | it("formats labels for non-macOS", () => { | |
| 351 | assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D"); | |
| 352 | }); | |
| 353 | ||
| 354 | it("formats labels for plus key", () => { | |
| 355 | assert.strictEqual(formatShortcutLabel(modShortcut("+"), "MacIntel"), "⌘+"); | |
| 356 | assert.strictEqual(formatShortcutLabel(modShortcut("+"), "Linux"), "Ctrl++"); | |
| 357 | }); | |
| 358 | }); | |
| 359 | ||
| 360 | describe("isTerminalClearShortcut", () => { | |
| 361 | it("matches Ctrl+L on all platforms", () => { | |
| 362 | assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")); | |
| 363 | assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")); | |
| 364 | }); | |
| 365 | ||
| 366 | it("matches Cmd+K on macOS", () => { | |
| 367 | assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")); | |
| 368 | }); | |
| 369 | ||
| 370 | it("ignores non-keydown events", () => { | |
| 371 | assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux")); | |
| 372 | }); | |
| 373 | }); | |
| 374 | ||
| 375 | describe("terminalNavigationShortcutData", () => { | |
| 376 | it("maps Option+Arrow on macOS to word movement", () => { | |
| 377 | assert.strictEqual( | |
| 378 | terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"), | |
| 379 | "\u001bb", | |
| 380 | ); | |
| 381 | assert.strictEqual( | |
| 382 | terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"), | |
| 383 | "\u001bf", | |
| 384 | ); | |
| 385 | }); | |
| 386 | ||
| 387 | it("maps Cmd+Arrow on macOS to line movement", () => { | |
| 388 | assert.strictEqual( | |
| 389 | terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"), | |
| 390 | "\u0001", | |
| 391 | ); | |
| 392 | assert.strictEqual( | |
| 393 | terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"), | |
| 394 | "\u0005", | |
| 395 | ); | |
| 396 | }); | |
| 397 | ||
| 398 | it("maps Ctrl+Arrow on non-macOS to word movement", () => { | |
| 399 | assert.strictEqual( | |
| 400 | terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"), | |
| 401 | "\u001bb", | |
| 402 | ); | |
| 403 | assert.strictEqual( | |
| 404 | terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"), | |
| 405 | "\u001bf", | |
| 406 | ); | |
| 407 | }); | |
| 408 | ||
| 409 | it("rejects unsupported combinations", () => { | |
| 410 | assert.isNull( | |
| 411 | terminalNavigationShortcutData( | |
| 412 | event({ key: "ArrowLeft", shiftKey: true, altKey: true }), | |
| 413 | "MacIntel", | |
| 414 | ), | |
| 415 | ); | |
| 416 | assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); | |
| 417 | assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); | |
| 418 | }); | |
| 419 | ||
| 420 | it("ignores non-keydown events", () => { | |
| 421 | assert.isNull( | |
| 422 | terminalNavigationShortcutData( | |
| 423 | event({ type: "keyup", key: "ArrowLeft", altKey: true }), | |
| 424 | "MacIntel", | |
| 425 | ), | |
| 426 | ); | |
| 427 | }); | |
| 428 | }); | |
| 429 | ||
| 430 | describe("plus key parsing", () => { | |
| 431 | it("matches the plus key shortcut", () => { | |
| 432 | const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]); | |
| 433 | assert.isTrue( | |
| 434 | isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, { | |
| 435 | platform: "MacIntel", | |
| 436 | }), | |
| 437 | ); | |
| 438 | assert.isTrue( | |
| 439 | isTerminalToggleShortcut(event({ key: "+", ctrlKey: true }), plusBindings, { | |
| 440 | platform: "Linux", | |
| 441 | }), | |
| 442 | ); | |
| 443 | }); | |
| 444 | }); |
apps/web/src/keybindings.ts created+286| ... | ... | @@ -0,0 +1,286 @@ |
| 1 | import { | |
| 2 | type KeybindingCommand, | |
| 3 | type KeybindingShortcut, | |
| 4 | type KeybindingWhenNode, | |
| 5 | type ResolvedKeybindingsConfig, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { isMacPlatform } from "./lib/utils"; | |
| 8 | ||
| 9 | export interface ShortcutEventLike { | |
| 10 | type?: string; | |
| 11 | key: string; | |
| 12 | metaKey: boolean; | |
| 13 | ctrlKey: boolean; | |
| 14 | shiftKey: boolean; | |
| 15 | altKey: boolean; | |
| 16 | } | |
| 17 | ||
| 18 | export interface ShortcutMatchContext { | |
| 19 | terminalFocus: boolean; | |
| 20 | terminalOpen: boolean; | |
| 21 | [key: string]: boolean; | |
| 22 | } | |
| 23 | ||
| 24 | interface ShortcutMatchOptions { | |
| 25 | platform?: string; | |
| 26 | context?: Partial<ShortcutMatchContext>; | |
| 27 | } | |
| 28 | ||
| 29 | const TERMINAL_WORD_BACKWARD = "\u001bb"; | |
| 30 | const TERMINAL_WORD_FORWARD = "\u001bf"; | |
| 31 | const TERMINAL_LINE_START = "\u0001"; | |
| 32 | const TERMINAL_LINE_END = "\u0005"; | |
| 33 | ||
| 34 | function normalizeEventKey(key: string): string { | |
| 35 | const normalized = key.toLowerCase(); | |
| 36 | if (normalized === "esc") return "escape"; | |
| 37 | return normalized; | |
| 38 | } | |
| 39 | ||
| 40 | function matchesShortcut( | |
| 41 | event: ShortcutEventLike, | |
| 42 | shortcut: KeybindingShortcut, | |
| 43 | platform = navigator.platform, | |
| 44 | ): boolean { | |
| 45 | const key = normalizeEventKey(event.key); | |
| 46 | if (key !== shortcut.key) return false; | |
| 47 | ||
| 48 | const useMetaForMod = isMacPlatform(platform); | |
| 49 | const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); | |
| 50 | const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); | |
| 51 | return ( | |
| 52 | event.metaKey === expectedMeta && | |
| 53 | event.ctrlKey === expectedCtrl && | |
| 54 | event.shiftKey === shortcut.shiftKey && | |
| 55 | event.altKey === shortcut.altKey | |
| 56 | ); | |
| 57 | } | |
| 58 | ||
| 59 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | |
| 60 | return options?.platform ?? navigator.platform; | |
| 61 | } | |
| 62 | ||
| 63 | function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { | |
| 64 | return { | |
| 65 | terminalFocus: false, | |
| 66 | terminalOpen: false, | |
| 67 | ...options?.context, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean { | |
| 72 | switch (node.type) { | |
| 73 | case "identifier": | |
| 74 | if (node.name === "true") return true; | |
| 75 | if (node.name === "false") return false; | |
| 76 | return Boolean(context[node.name]); | |
| 77 | case "not": | |
| 78 | return !evaluateWhenNode(node.node, context); | |
| 79 | case "and": | |
| 80 | return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); | |
| 81 | case "or": | |
| 82 | return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | function matchesWhenClause( | |
| 87 | whenAst: KeybindingWhenNode | undefined, | |
| 88 | context: ShortcutMatchContext, | |
| 89 | ): boolean { | |
| 90 | if (!whenAst) return true; | |
| 91 | return evaluateWhenNode(whenAst, context); | |
| 92 | } | |
| 93 | ||
| 94 | function matchesCommandShortcut( | |
| 95 | event: ShortcutEventLike, | |
| 96 | keybindings: ResolvedKeybindingsConfig, | |
| 97 | command: KeybindingCommand, | |
| 98 | options?: ShortcutMatchOptions, | |
| 99 | ): boolean { | |
| 100 | return resolveShortcutCommand(event, keybindings, options) === command; | |
| 101 | } | |
| 102 | ||
| 103 | function resolveShortcutCommand( | |
| 104 | event: ShortcutEventLike, | |
| 105 | keybindings: ResolvedKeybindingsConfig, | |
| 106 | options?: ShortcutMatchOptions, | |
| 107 | ): KeybindingCommand | null { | |
| 108 | const platform = resolvePlatform(options); | |
| 109 | const context = resolveContext(options); | |
| 110 | ||
| 111 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 112 | const binding = keybindings[index]; | |
| 113 | if (!binding) continue; | |
| 114 | if (!matchesWhenClause(binding.whenAst, context)) continue; | |
| 115 | if (!matchesShortcut(event, binding.shortcut, platform)) continue; | |
| 116 | return binding.command; | |
| 117 | } | |
| 118 | return null; | |
| 119 | } | |
| 120 | ||
| 121 | function formatShortcutKeyLabel(key: string): string { | |
| 122 | if (key === " ") return "Space"; | |
| 123 | if (key.length === 1) return key.toUpperCase(); | |
| 124 | if (key === "escape") return "Esc"; | |
| 125 | if (key === "arrowup") return "Up"; | |
| 126 | if (key === "arrowdown") return "Down"; | |
| 127 | if (key === "arrowleft") return "Left"; | |
| 128 | if (key === "arrowright") return "Right"; | |
| 129 | return key.slice(0, 1).toUpperCase() + key.slice(1); | |
| 130 | } | |
| 131 | ||
| 132 | export function formatShortcutLabel( | |
| 133 | shortcut: KeybindingShortcut, | |
| 134 | platform = navigator.platform, | |
| 135 | ): string { | |
| 136 | const keyLabel = formatShortcutKeyLabel(shortcut.key); | |
| 137 | const useMetaForMod = isMacPlatform(platform); | |
| 138 | const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); | |
| 139 | const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); | |
| 140 | const showAlt = shortcut.altKey; | |
| 141 | const showShift = shortcut.shiftKey; | |
| 142 | ||
| 143 | if (useMetaForMod) { | |
| 144 | return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; | |
| 145 | } | |
| 146 | ||
| 147 | const parts: string[] = []; | |
| 148 | if (showCtrl) parts.push("Ctrl"); | |
| 149 | if (showAlt) parts.push("Alt"); | |
| 150 | if (showShift) parts.push("Shift"); | |
| 151 | if (showMeta) parts.push("Meta"); | |
| 152 | parts.push(keyLabel); | |
| 153 | return parts.join("+"); | |
| 154 | } | |
| 155 | ||
| 156 | export function shortcutLabelForCommand( | |
| 157 | keybindings: ResolvedKeybindingsConfig, | |
| 158 | command: KeybindingCommand, | |
| 159 | platform = navigator.platform, | |
| 160 | ): string | null { | |
| 161 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 162 | const binding = keybindings[index]; | |
| 163 | if (!binding || binding.command !== command) continue; | |
| 164 | return formatShortcutLabel(binding.shortcut, platform); | |
| 165 | } | |
| 166 | return null; | |
| 167 | } | |
| 168 | ||
| 169 | export function isTerminalToggleShortcut( | |
| 170 | event: ShortcutEventLike, | |
| 171 | keybindings: ResolvedKeybindingsConfig, | |
| 172 | options?: ShortcutMatchOptions, | |
| 173 | ): boolean { | |
| 174 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); | |
| 175 | } | |
| 176 | ||
| 177 | export function isTerminalSplitShortcut( | |
| 178 | event: ShortcutEventLike, | |
| 179 | keybindings: ResolvedKeybindingsConfig, | |
| 180 | options?: ShortcutMatchOptions, | |
| 181 | ): boolean { | |
| 182 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | |
| 183 | } | |
| 184 | ||
| 185 | export function isTerminalNewShortcut( | |
| 186 | event: ShortcutEventLike, | |
| 187 | keybindings: ResolvedKeybindingsConfig, | |
| 188 | options?: ShortcutMatchOptions, | |
| 189 | ): boolean { | |
| 190 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | |
| 191 | } | |
| 192 | ||
| 193 | export function isTerminalCloseShortcut( | |
| 194 | event: ShortcutEventLike, | |
| 195 | keybindings: ResolvedKeybindingsConfig, | |
| 196 | options?: ShortcutMatchOptions, | |
| 197 | ): boolean { | |
| 198 | return matchesCommandShortcut(event, keybindings, "terminal.close", options); | |
| 199 | } | |
| 200 | ||
| 201 | export function isChatNewShortcut( | |
| 202 | event: ShortcutEventLike, | |
| 203 | keybindings: ResolvedKeybindingsConfig, | |
| 204 | options?: ShortcutMatchOptions, | |
| 205 | ): boolean { | |
| 206 | return matchesCommandShortcut(event, keybindings, "chat.new", options); | |
| 207 | } | |
| 208 | ||
| 209 | export function isChatNewLocalShortcut( | |
| 210 | event: ShortcutEventLike, | |
| 211 | keybindings: ResolvedKeybindingsConfig, | |
| 212 | options?: ShortcutMatchOptions, | |
| 213 | ): boolean { | |
| 214 | return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); | |
| 215 | } | |
| 216 | ||
| 217 | export function isOpenFavoriteEditorShortcut( | |
| 218 | event: ShortcutEventLike, | |
| 219 | keybindings: ResolvedKeybindingsConfig, | |
| 220 | options?: ShortcutMatchOptions, | |
| 221 | ): boolean { | |
| 222 | return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); | |
| 223 | } | |
| 224 | ||
| 225 | export function isTerminalClearShortcut( | |
| 226 | event: ShortcutEventLike, | |
| 227 | platform = navigator.platform, | |
| 228 | ): boolean { | |
| 229 | if (event.type !== undefined && event.type !== "keydown") { | |
| 230 | return false; | |
| 231 | } | |
| 232 | ||
| 233 | const key = event.key.toLowerCase(); | |
| 234 | ||
| 235 | if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { | |
| 236 | return true; | |
| 237 | } | |
| 238 | ||
| 239 | return ( | |
| 240 | isMacPlatform(platform) && | |
| 241 | key === "k" && | |
| 242 | event.metaKey && | |
| 243 | !event.ctrlKey && | |
| 244 | !event.altKey && | |
| 245 | !event.shiftKey | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | export function terminalNavigationShortcutData( | |
| 250 | event: ShortcutEventLike, | |
| 251 | platform = navigator.platform, | |
| 252 | ): string | null { | |
| 253 | if (event.type !== undefined && event.type !== "keydown") { | |
| 254 | return null; | |
| 255 | } | |
| 256 | ||
| 257 | if (event.shiftKey) return null; | |
| 258 | ||
| 259 | const key = normalizeEventKey(event.key); | |
| 260 | if (key !== "arrowleft" && key !== "arrowright") { | |
| 261 | return null; | |
| 262 | } | |
| 263 | ||
| 264 | const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD; | |
| 265 | const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END; | |
| 266 | ||
| 267 | if (isMacPlatform(platform)) { | |
| 268 | if (event.altKey && !event.metaKey && !event.ctrlKey) { | |
| 269 | return moveWord; | |
| 270 | } | |
| 271 | if (event.metaKey && !event.altKey && !event.ctrlKey) { | |
| 272 | return moveLine; | |
| 273 | } | |
| 274 | return null; | |
| 275 | } | |
| 276 | ||
| 277 | if (event.ctrlKey && !event.metaKey && !event.altKey) { | |
| 278 | return moveWord; | |
| 279 | } | |
| 280 | ||
| 281 | if (event.altKey && !event.metaKey && !event.ctrlKey) { | |
| 282 | return moveWord; | |
| 283 | } | |
| 284 | ||
| 285 | return null; | |
| 286 | } |
apps/web/src/lib/serverReactQuery.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | import { queryOptions } from "@tanstack/react-query"; | |
| 3 | ||
| 4 | export const serverQueryKeys = { | |
| 5 | all: ["server"] as const, | |
| 6 | config: () => ["server", "config"] as const, | |
| 7 | }; | |
| 8 | ||
| 9 | export function serverConfigQueryOptions(api: NativeApi | undefined) { | |
| 10 | return queryOptions({ | |
| 11 | queryKey: serverQueryKeys.config(), | |
| 12 | queryFn: async () => { | |
| 13 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | |
| 14 | throw new Error("Server config is unavailable."); | |
| 15 | } | |
| 16 | return api.server.getConfig(); | |
| 17 | }, | |
| 18 | enabled: !!api && !!api.server && typeof api.server.getConfig === "function", | |
| 19 | staleTime: Infinity, | |
| 20 | }); | |
| 21 | } |
apps/web/src/lib/utils.test.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 2 | ||
| 3 | import { isWindowsPlatform } from "./utils"; | |
| 4 | ||
| 5 | describe("isWindowsPlatform", () => { | |
| 6 | it("matches Windows platform identifiers", () => { | |
| 7 | assert.isTrue(isWindowsPlatform("Win32")); | |
| 8 | assert.isTrue(isWindowsPlatform("Windows")); | |
| 9 | assert.isTrue(isWindowsPlatform("windows_nt")); | |
| 10 | }); | |
| 11 | ||
| 12 | it("does not match darwin", () => { | |
| 13 | assert.isFalse(isWindowsPlatform("darwin")); | |
| 14 | }); | |
| 15 | }); |
apps/web/src/lib/utils.ts+8| ... | ... | @@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"; |
| 4 | 4 | export function cn(...inputs: CxOptions) { |
| 5 | 5 | return twMerge(cx(inputs)); |
| 6 | 6 | } |
| 7 | ||
| 8 | export function isMacPlatform(platform: string): boolean { | |
| 9 | return /mac|iphone|ipad|ipod/i.test(platform); | |
| 10 | } | |
| 11 | ||
| 12 | export function isWindowsPlatform(platform: string): boolean { | |
| 13 | return /^win(dows)?/i.test(platform); | |
| 14 | } |
apps/web/src/store.test.ts+45| ... | ... | @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { |
| 486 | 486 | expect(next.activeThreadId).toBe("thread-a"); |
| 487 | 487 | }); |
| 488 | 488 | |
| 489 | it("deletes a project and all of its threads", () => { | |
| 490 | const state: AppState = { | |
| 491 | projects: [ | |
| 492 | { | |
| 493 | id: "project-1", | |
| 494 | name: "One", | |
| 495 | cwd: "/tmp/one", | |
| 496 | model: "gpt-5.3-codex", | |
| 497 | expanded: true, | |
| 498 | }, | |
| 499 | { | |
| 500 | id: "project-2", | |
| 501 | name: "Two", | |
| 502 | cwd: "/tmp/two", | |
| 503 | model: "gpt-5.3-codex", | |
| 504 | expanded: true, | |
| 505 | }, | |
| 506 | ], | |
| 507 | threads: [ | |
| 508 | makeThread({ | |
| 509 | id: "thread-a", | |
| 510 | projectId: "project-1", | |
| 511 | }), | |
| 512 | makeThread({ | |
| 513 | id: "thread-b", | |
| 514 | projectId: "project-2", | |
| 515 | }), | |
| 516 | ], | |
| 517 | activeThreadId: "thread-a", | |
| 518 | runtimeMode: "full-access", | |
| 519 | diffOpen: false, | |
| 520 | }; | |
| 521 | ||
| 522 | const next = reducer(state, { | |
| 523 | type: "DELETE_PROJECT", | |
| 524 | projectId: "project-1", | |
| 525 | }); | |
| 526 | ||
| 527 | expect(next.projects).toHaveLength(1); | |
| 528 | expect(next.projects[0]?.id).toBe("project-2"); | |
| 529 | expect(next.threads).toHaveLength(1); | |
| 530 | expect(next.threads[0]?.id).toBe("thread-b"); | |
| 531 | expect(next.activeThreadId).toBe("thread-b"); | |
| 532 | }); | |
| 533 | ||
| 489 | 534 | it("marks the active thread as visited when selected", () => { |
| 490 | 535 | const state = makeState( |
| 491 | 536 | makeThread({ |
apps/web/src/store.ts+20| ... | ... | @@ -28,6 +28,7 @@ type Action = |
| 28 | 28 | | { type: "ADD_PROJECT"; project: Project } |
| 29 | 29 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 30 | 30 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 31 | | { type: "DELETE_PROJECT"; projectId: string } | |
| 31 | 32 | | { type: "ADD_THREAD"; thread: Thread } |
| 32 | 33 | | { type: "SET_ACTIVE_THREAD"; threadId: string } |
| 33 | 34 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| ... | ... | @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { |
| 473 | 474 | ), |
| 474 | 475 | }; |
| 475 | 476 | |
| 477 | case "DELETE_PROJECT": { | |
| 478 | const projects = state.projects.filter((project) => project.id !== action.projectId); | |
| 479 | if (projects.length === state.projects.length) { | |
| 480 | return state; | |
| 481 | } | |
| 482 | ||
| 483 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); | |
| 484 | const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) | |
| 485 | ? state.activeThreadId | |
| 486 | : (threads[0]?.id ?? null); | |
| 487 | ||
| 488 | return { | |
| 489 | ...state, | |
| 490 | projects, | |
| 491 | threads, | |
| 492 | activeThreadId, | |
| 493 | }; | |
| 494 | } | |
| 495 | ||
| 476 | 496 | case "ADD_THREAD": { |
| 477 | 497 | const nextThread = normalizeThreadTerminals({ |
| 478 | 498 | ...action.thread, |
apps/web/src/terminal-links.ts+1-4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 2 | import { isMacPlatform } from "./lib/utils"; | |
| 2 | 3 | |
| 3 | 4 | export type TerminalLinkKind = "url" | "path"; |
| 4 | 5 | |
| ... | ... | @@ -15,10 +16,6 @@ const FILE_PATH_PATTERN = |
| 15 | 16 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; |
| 16 | 17 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 17 | 18 | |
| 18 | function isMacPlatform(platform: string): boolean { | |
| 19 | return /mac|iphone|ipad|ipod/i.test(platform); | |
| 20 | } | |
| 21 | ||
| 22 | 19 | function trimClosingDelimiters(value: string): string { |
| 23 | 20 | let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); |
| 24 | 21 | if (output.length === 0) return output; |
apps/web/src/terminal-shortcuts.test.ts deleted-63| ... | ... | @@ -1,63 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | isTerminalClearShortcut, | |
| 5 | isTerminalToggleShortcut, | |
| 6 | type ShortcutEventLike, | |
| 7 | } from "./terminal-shortcuts"; | |
| 8 | ||
| 9 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | |
| 10 | return { | |
| 11 | key: "j", | |
| 12 | metaKey: false, | |
| 13 | ctrlKey: false, | |
| 14 | shiftKey: false, | |
| 15 | altKey: false, | |
| 16 | ...overrides, | |
| 17 | }; | |
| 18 | } | |
| 19 | ||
| 20 | describe("isTerminalToggleShortcut", () => { | |
| 21 | it("matches Cmd+J on macOS", () => { | |
| 22 | expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); | |
| 23 | }); | |
| 24 | ||
| 25 | it("matches Ctrl+J on non-macOS", () => { | |
| 26 | expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true); | |
| 27 | }); | |
| 28 | ||
| 29 | it("rejects wrong modifiers", () => { | |
| 30 | expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false); | |
| 31 | expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe( | |
| 32 | false, | |
| 33 | ); | |
| 34 | }); | |
| 35 | ||
| 36 | it("rejects non-j keys", () => { | |
| 37 | expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("isTerminalClearShortcut", () => { | |
| 42 | it("matches Ctrl+L on all platforms", () => { | |
| 43 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); | |
| 44 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); | |
| 45 | }); | |
| 46 | ||
| 47 | it("matches Cmd+K on macOS", () => { | |
| 48 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); | |
| 49 | }); | |
| 50 | ||
| 51 | it("rejects Cmd+K on non-macOS", () => { | |
| 52 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false); | |
| 53 | }); | |
| 54 | ||
| 55 | it("rejects wrong modifiers", () => { | |
| 56 | expect( | |
| 57 | isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"), | |
| 58 | ).toBe(false); | |
| 59 | expect( | |
| 60 | isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"), | |
| 61 | ).toBe(false); | |
| 62 | }); | |
| 63 | }); |
apps/web/src/terminal-shortcuts.ts deleted-46| ... | ... | @@ -1,46 +0,0 @@ |
| 1 | export interface ShortcutEventLike { | |
| 2 | key: string; | |
| 3 | metaKey: boolean; | |
| 4 | ctrlKey: boolean; | |
| 5 | shiftKey: boolean; | |
| 6 | altKey: boolean; | |
| 7 | } | |
| 8 | ||
| 9 | function isMacPlatform(platform: string): boolean { | |
| 10 | return /mac|iphone|ipad|ipod/i.test(platform); | |
| 11 | } | |
| 12 | ||
| 13 | export function isTerminalToggleShortcut( | |
| 14 | event: ShortcutEventLike, | |
| 15 | platform = navigator.platform, | |
| 16 | ): boolean { | |
| 17 | const key = event.key.toLowerCase(); | |
| 18 | if (key !== "j") return false; | |
| 19 | if (event.shiftKey || event.altKey) return false; | |
| 20 | ||
| 21 | if (isMacPlatform(platform)) { | |
| 22 | return event.metaKey && !event.ctrlKey; | |
| 23 | } | |
| 24 | ||
| 25 | return event.ctrlKey && !event.metaKey; | |
| 26 | } | |
| 27 | ||
| 28 | export function isTerminalClearShortcut( | |
| 29 | event: ShortcutEventLike, | |
| 30 | platform = navigator.platform, | |
| 31 | ): boolean { | |
| 32 | const key = event.key.toLowerCase(); | |
| 33 | ||
| 34 | if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { | |
| 35 | return true; | |
| 36 | } | |
| 37 | ||
| 38 | return ( | |
| 39 | isMacPlatform(platform) && | |
| 40 | key === "k" && | |
| 41 | event.metaKey && | |
| 42 | !event.ctrlKey && | |
| 43 | !event.altKey && | |
| 44 | !event.shiftKey | |
| 45 | ); | |
| 46 | } |
apps/web/src/wsNativeApi.ts+3| ... | ... | @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { |
| 139 | 139 | return showContextMenuFallback(items, position); |
| 140 | 140 | }, |
| 141 | 141 | }, |
| 142 | server: { | |
| 143 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | |
| 144 | }, | |
| 142 | 145 | }; |
| 143 | 146 | |
| 144 | 147 | instance = { api, transport }; |
packages/contracts/src/index.ts+2| ... | ... | @@ -5,5 +5,7 @@ export * from "./terminal"; |
| 5 | 5 | export * from "./provider"; |
| 6 | 6 | export * from "./model"; |
| 7 | 7 | export * from "./ws"; |
| 8 | export * from "./keybindings"; | |
| 9 | export * from "./server"; | |
| 8 | 10 | export * from "./project"; |
| 9 | 11 | export * from "./git"; |
packages/contracts/src/ipc.ts+4| ... | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | 31 | ProjectListResult, |
| 32 | 32 | ProjectRemoveInput, |
| 33 | 33 | } from "./project"; |
| 34 | import type { ServerConfig } from "./server"; | |
| 34 | 35 | import type { |
| 35 | 36 | TerminalClearInput, |
| 36 | 37 | TerminalCloseInput, |
| ... | ... | @@ -113,4 +114,7 @@ export interface NativeApi { |
| 113 | 114 | position?: { x: number; y: number }, |
| 114 | 115 | ) => Promise<T | null>; |
| 115 | 116 | }; |
| 117 | server: { | |
| 118 | getConfig: () => Promise<ServerConfig>; | |
| 119 | }; | |
| 116 | 120 | } |
packages/contracts/src/keybindings.test.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | keybindingRuleSchema, | |
| 5 | keybindingsConfigSchema, | |
| 6 | resolvedKeybindingRuleSchema, | |
| 7 | resolvedKeybindingsConfigSchema, | |
| 8 | } from "./keybindings"; | |
| 9 | ||
| 10 | describe("keybindings contracts", () => { | |
| 11 | it("parses keybinding rules", () => { | |
| 12 | const parsed = keybindingRuleSchema.parse({ | |
| 13 | key: "mod+j", | |
| 14 | command: "terminal.toggle", | |
| 15 | }); | |
| 16 | assert.strictEqual(parsed.command, "terminal.toggle"); | |
| 17 | ||
| 18 | const parsedClose = keybindingRuleSchema.parse({ | |
| 19 | key: "mod+w", | |
| 20 | command: "terminal.close", | |
| 21 | }); | |
| 22 | assert.strictEqual(parsedClose.command, "terminal.close"); | |
| 23 | ||
| 24 | const parsedLocal = keybindingRuleSchema.parse({ | |
| 25 | key: "mod+shift+n", | |
| 26 | command: "chat.newLocal", | |
| 27 | }); | |
| 28 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("rejects invalid command values", () => { | |
| 32 | assert.throws(() => | |
| 33 | keybindingRuleSchema.parse({ | |
| 34 | key: "mod+j", | |
| 35 | command: "invalid.command", | |
| 36 | }), | |
| 37 | ); | |
| 38 | }); | |
| 39 | ||
| 40 | it("parses keybindings array payload", () => { | |
| 41 | const parsed = keybindingsConfigSchema.parse([ | |
| 42 | { key: "mod+j", command: "terminal.toggle" }, | |
| 43 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 44 | ]); | |
| 45 | assert.lengthOf(parsed, 2); | |
| 46 | }); | |
| 47 | ||
| 48 | it("parses resolved keybinding rules", () => { | |
| 49 | const parsed = resolvedKeybindingRuleSchema.parse({ | |
| 50 | command: "terminal.split", | |
| 51 | shortcut: { | |
| 52 | key: "d", | |
| 53 | metaKey: false, | |
| 54 | ctrlKey: false, | |
| 55 | shiftKey: false, | |
| 56 | altKey: false, | |
| 57 | modKey: true, | |
| 58 | }, | |
| 59 | whenAst: { | |
| 60 | type: "and", | |
| 61 | left: { type: "identifier", name: "terminalOpen" }, | |
| 62 | right: { | |
| 63 | type: "not", | |
| 64 | node: { type: "identifier", name: "terminalFocus" }, | |
| 65 | }, | |
| 66 | }, | |
| 67 | }); | |
| 68 | assert.strictEqual(parsed.shortcut.key, "d"); | |
| 69 | }); | |
| 70 | ||
| 71 | it("parses resolved keybindings arrays", () => { | |
| 72 | const parsed = resolvedKeybindingsConfigSchema.parse([ | |
| 73 | { | |
| 74 | command: "terminal.toggle", | |
| 75 | shortcut: { | |
| 76 | key: "j", | |
| 77 | metaKey: false, | |
| 78 | ctrlKey: false, | |
| 79 | shiftKey: false, | |
| 80 | altKey: false, | |
| 81 | modKey: true, | |
| 82 | }, | |
| 83 | }, | |
| 84 | ]); | |
| 85 | assert.lengthOf(parsed, 1); | |
| 86 | }); | |
| 87 | ||
| 88 | it("rejects unknown fields in resolved keybinding rules", () => { | |
| 89 | assert.throws(() => | |
| 90 | resolvedKeybindingRuleSchema.parse({ | |
| 91 | command: "terminal.toggle", | |
| 92 | shortcut: { | |
| 93 | key: "j", | |
| 94 | metaKey: false, | |
| 95 | ctrlKey: false, | |
| 96 | shiftKey: false, | |
| 97 | altKey: false, | |
| 98 | modKey: true, | |
| 99 | }, | |
| 100 | key: "mod+j", | |
| 101 | }), | |
| 102 | ); | |
| 103 | }); | |
| 104 | }); |
packages/contracts/src/keybindings.ts created+75| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const keybindingCommandSchema = z.enum([ | |
| 4 | "terminal.toggle", | |
| 5 | "terminal.split", | |
| 6 | "terminal.new", | |
| 7 | "terminal.close", | |
| 8 | "chat.new", | |
| 9 | "chat.newLocal", | |
| 10 | "editor.openFavorite", | |
| 11 | ]); | |
| 12 | ||
| 13 | const keybindingValueSchema = z.string().trim().min(1).max(64); | |
| 14 | const keybindingWhenSchema = z.string().trim().min(1).max(256); | |
| 15 | ||
| 16 | export const keybindingRuleSchema = z.object({ | |
| 17 | key: keybindingValueSchema, | |
| 18 | command: keybindingCommandSchema, | |
| 19 | when: keybindingWhenSchema.optional(), | |
| 20 | }); | |
| 21 | ||
| 22 | export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); | |
| 23 | ||
| 24 | export const keybindingShortcutSchema = z.object({ | |
| 25 | key: z.string().trim().min(1).max(32), | |
| 26 | metaKey: z.boolean(), | |
| 27 | ctrlKey: z.boolean(), | |
| 28 | shiftKey: z.boolean(), | |
| 29 | altKey: z.boolean(), | |
| 30 | modKey: z.boolean(), | |
| 31 | }).strict(); | |
| 32 | ||
| 33 | export type KeybindingWhenNode = | |
| 34 | | { type: "identifier"; name: string } | |
| 35 | | { type: "not"; node: KeybindingWhenNode } | |
| 36 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } | |
| 37 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; | |
| 38 | ||
| 39 | export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() => | |
| 40 | z.union([ | |
| 41 | z.object({ | |
| 42 | type: z.literal("identifier"), | |
| 43 | name: z.string().min(1), | |
| 44 | }), | |
| 45 | z.object({ | |
| 46 | type: z.literal("not"), | |
| 47 | node: keybindingWhenNodeSchema, | |
| 48 | }), | |
| 49 | z.object({ | |
| 50 | type: z.literal("and"), | |
| 51 | left: keybindingWhenNodeSchema, | |
| 52 | right: keybindingWhenNodeSchema, | |
| 53 | }), | |
| 54 | z.object({ | |
| 55 | type: z.literal("or"), | |
| 56 | left: keybindingWhenNodeSchema, | |
| 57 | right: keybindingWhenNodeSchema, | |
| 58 | }), | |
| 59 | ]), | |
| 60 | ); | |
| 61 | ||
| 62 | export const resolvedKeybindingRuleSchema = z.object({ | |
| 63 | command: keybindingCommandSchema, | |
| 64 | shortcut: keybindingShortcutSchema, | |
| 65 | whenAst: keybindingWhenNodeSchema.optional(), | |
| 66 | }).strict(); | |
| 67 | ||
| 68 | export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); | |
| 69 | ||
| 70 | export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>; | |
| 71 | export type KeybindingRule = z.infer<typeof keybindingRuleSchema>; | |
| 72 | export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>; | |
| 73 | export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>; | |
| 74 | export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>; | |
| 75 | export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>; |
packages/contracts/src/server.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | import { z } from "zod"; | |
| 2 | import { resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 3 | ||
| 4 | export const serverConfigSchema = z.object({ | |
| 5 | cwd: z.string().min(1), | |
| 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 7 | }); | |
| 8 | ||
| 9 | export type ServerConfig = z.infer<typeof serverConfigSchema>; |