Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
disabled={
!canOperateThread ||
isEnvironmentUnavailable ||
isSendDisabled ||
pendingAction.isResponding ||
(pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
}
Expand Down
27 changes: 22 additions & 5 deletions apps/web/src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
unstyled?: boolean;
};

function Textarea({ className, size = "default", unstyled = false, ...props }: TextareaProps) {
function Textarea({
className,
size = "default",
unstyled = false,
rows,
...props
}: TextareaProps) {
return (
<span
className={
Expand All @@ -26,12 +32,23 @@ function Textarea({ className, size = "default", unstyled = false, ...props }: T
render={(defaultProps) => (
<textarea
className={cn(
"field-sizing-content min-h-17.5 max-h-64 w-full rounded-[inherit] px-[calc(--spacing(3)-1px)] py-[calc(--spacing(1.5)-1px)] outline-none wrap-anywhere max-sm:min-h-20.5",
size === "sm" &&
"min-h-16.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] max-sm:min-h-19.5",
size === "lg" && "min-h-18.5 py-[calc(--spacing(2)-1px)] max-sm:min-h-21.5",
"field-sizing-content max-h-64 w-full rounded-[inherit] px-[calc(--spacing(3)-1px)] py-[calc(--spacing(1.5)-1px)] outline-none wrap-anywhere",
rows === undefined && "min-h-17.5 max-sm:min-h-20.5",
size === "sm" && "px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)]",
size === "sm" && rows === undefined && "min-h-16.5 max-sm:min-h-19.5",
size === "lg" && "py-[calc(--spacing(2)-1px)]",
size === "lg" && rows === undefined && "min-h-18.5 max-sm:min-h-21.5",
)}
// field-sizing: content ignores `rows`, so an explicit row count sets the minimum height.
style={
rows === undefined
? undefined
: {
minHeight: `calc(${rows}lh + 2 * (var(--spacing) * ${size === "sm" ? 1 : size === "lg" ? 2 : 1.5} - 1px))`,
}
}
data-slot="textarea"
rows={rows}
{...mergeProps(defaultProps, props)}
/>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export function readLocalStorageValue<T, E>(
/** Fires on writes from this tab and from other tabs. */
export function subscribeLocalStorageKey(key: string, onChange: () => void): () => void {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
if (event.key === key || event.key === null) {
onChange();
}
};
Expand Down
28 changes: 18 additions & 10 deletions docs/internals/coder-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,9 @@ checks that every name here still exists and that every modified upstream file i
size.
- Cached data and drafts are cleared only when a workspace leaves the Coder config (stopped and
disconnected workspaces also leave the platform registrations); the mapping is memory-only.
- client-runtime's RPC session sends no `providerCatalogsOnDemand` subscribe option (#28487); only
upstream's mobile app sets it, so the server keeps sending every catalog. The server side
(`ws.ts`'s `catalogsPerCwd`) is upstream's.
- `state/server.ts` (client-runtime) and the web provider list stay limited to the helper's methods
and the four drivers (`isCoderProviderDriver`); `providerClients.ts` registers only those, and the
Add provider dialog shows all four with no "Show more" fold or ACP Registry search (#25753).
Expand Down Expand Up @@ -1247,16 +1250,21 @@ them, the file is named here.
would add a second laptop-to-workspace upload. Not carried: server `speech/`, web `speech/`, the
voice settings page and composer controls, `TranscriptionPostProcessing.ts`, contracts
`speech.ts` with its settings fields, the `voiceTranscription` and `cloudVoiceTranscription`
capabilities (`environment.ts`), and the `composer.dictation` command (`keybindings.ts`), shared
`speech.ts`, `keybindingMatching.ts`, and `HostProcess.ts`'s `LinuxLibc`, provider-core
`textGeneration.ts` and `textGenerationOperations.ts`'s transcript cleanup, and the dictation
hooks #30623 added to carried text fields: `AssistantCitationChip.tsx`,
`AssistantCitationCommentEditor.tsx`, `ComposerPrimaryActions.tsx`, `AnnotatableCodeView.tsx`,
`DiffCommentAnnotation.tsx`, `PullRequestCommentForm.tsx`, `PullRequestComposer.tsx`,
`PullRequestReviewAnnotation.tsx`, `ui/button.tsx`, `ui/textarea.tsx`, and `useLocalStorage.ts`.
Those files are upstream's with the voice commits reverted; the later PR completion hunks
(#30747, #30857) stay. `PullRequestReviewForm.tsx` keeps #30623's Cmd/Ctrl+Enter submit and
`commentSubmitShortcut.ts` is upstream's; only the review form's speech hooks are cut.
capabilities (`environment.ts`), the `composer.dictation` command (contracts `keybindings.ts`)
and its default binding (shared `keybindings.ts`), shared `speech.ts` and `HostProcess.ts`'s
`LinuxLibc`, provider-core `textGeneration.ts` and `textGenerationOperations.ts`'s transcript
cleanup, `ui/button.tsx`'s pill sizes (used only by the speech button), and the dictation hunks
in carried files: `AssistantCitationChip.tsx`, `AssistantCitationCommentEditor.tsx`,
`ChatComposer.tsx`, `ComposerFooterControls.tsx`, `ComposerPrimaryActions.tsx` (the
`voiceInputActive` gate only), `AnnotatableCodeView.tsx`, `DiffCommentAnnotation.tsx`,
`DiffPanel.tsx`, `FilePreviewPanel.tsx`, `PullRequestCodeTab.tsx`, `PullRequestCommentForm.tsx`,
`PullRequestComposer.tsx`, `PullRequestReviewAnnotation.tsx`, `PullRequestReviewForm.tsx` (its
Cmd/Ctrl+Enter submit stays), `KeybindingsSettings.logic.ts`, `settingsLayout.tsx`
(`searchTargetFallbacks`), `settingsSearch.ts`, `SettingsSidebarNav.tsx`, and `routeTree.gen.ts`.
Those files are upstream's with the voice hunks reverted; the later PR completion hunks (#30747,
#30857) stay. #30623's non-voice changes are carried: `commentSubmitShortcut.ts`,
`ui/textarea.tsx`, `useLocalStorage.ts`, and shared `keybindingMatching.ts` (#30624; no package
export, which only the desktop app imports).
- **Managed policy** (#27197). Organization-wide policy files, the Windows registry and macOS
profile readers (`ffi-rs`), `t3 policy`, policy-locked settings and providers, runtime-mode
ceilings, and their notices are cut: no contracts `managedPolicy.ts`, shared
Expand Down
106 changes: 106 additions & 0 deletions packages/shared/src/keybindingMatching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { KeybindingShortcut } from "@t3tools/contracts";

// Matching only: the desktop preview guest bundles this file without the rest of the
// keybindings module, so it stays free of parsing, defaults, and contracts runtime code.

export interface ShortcutEventLike {
getModifierState?: (key: "AltGraph") => boolean;
type?: string;
code?: string;
key: string;
repeat?: boolean;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
}

export interface ShortcutModifierStateLike {
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
}

export const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = {
Backquote: "`",
Backslash: "\\",
BracketLeft: "[",
BracketRight: "]",
Comma: ",",
Digit0: "0",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Equal: "=",
Minus: "-",
Period: ".",
Quote: "'",
Semicolon: ";",
Slash: "/",
};

export function isMacPlatform(platform: string): boolean {
return /mac|iphone|ipad|ipod/i.test(platform);
}

export function normalizeEventKey(key: string): string {
const normalized = key.toLowerCase();
if (normalized === "esc") return "escape";
return normalized;
}

export function shortcutKeyFromEvent(event: Pick<ShortcutEventLike, "key" | "code">): string {
const layoutKey = normalizeEventKey(event.key);
if (/^[a-z]$/.test(layoutKey)) return layoutKey;
const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
return physicalKey ?? layoutKey;
}

export function resolveEventKeys(event: ShortcutEventLike): Set<string> {
const layoutKey = normalizeEventKey(event.key);
const keys = new Set([layoutKey]);
const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
if (letterCode && !/^[a-z]$/.test(layoutKey)) {
keys.add(letterCode.toLowerCase());
}
keys.add(shortcutKeyFromEvent(event));
return keys;
}

export function matchesKeybindingShortcutModifiers(
event: ShortcutModifierStateLike,
shortcut: KeybindingShortcut,
platform: string,
): boolean {
const useMetaForMod = isMacPlatform(platform);
const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
return (
event.metaKey === expectedMeta &&
event.ctrlKey === expectedCtrl &&
event.shiftKey === shortcut.shiftKey &&
event.altKey === shortcut.altKey
);
}

export function matchesKeybindingShortcut(
event: ShortcutEventLike,
shortcut: KeybindingShortcut,
platform: string,
): boolean {
if (
!isMacPlatform(platform) &&
event.getModifierState?.("AltGraph") &&
!/^[a-z0-9]$/i.test(event.key)
)
return false;
if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false;
return resolveEventKeys(event).has(shortcut.key);
}
112 changes: 11 additions & 101 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,17 @@ import {
THREAD_JUMP_KEYBINDING_COMMANDS,
} from "@t3tools/contracts";

export interface ShortcutEventLike {
getModifierState?: (key: "AltGraph") => boolean;
type?: string;
code?: string;
key: string;
repeat?: boolean;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
}

export interface ShortcutModifierStateLike {
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
}

export const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = {
Backquote: "`",
Backslash: "\\",
BracketLeft: "[",
BracketRight: "]",
Comma: ",",
Digit0: "0",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Equal: "=",
Minus: "-",
Period: ".",
Quote: "'",
Semicolon: ";",
Slash: "/",
};
export {
EVENT_CODE_SHORTCUT_KEYS,
isMacPlatform,
matchesKeybindingShortcut,
matchesKeybindingShortcutModifiers,
normalizeEventKey,
resolveEventKeys,
shortcutKeyFromEvent,
type ShortcutEventLike,
type ShortcutModifierStateLike,
} from "./keybindingMatching.ts";

export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap<string, string> = new Map([
["!", "1"],
Expand All @@ -77,65 +46,6 @@ export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap<string, string> = new Map
["?", "/"],
]);

export function isMacPlatform(platform: string): boolean {
return /mac|iphone|ipad|ipod/i.test(platform);
}

export function normalizeEventKey(key: string): string {
const normalized = key.toLowerCase();
if (normalized === "esc") return "escape";
return normalized;
}

export function shortcutKeyFromEvent(event: Pick<ShortcutEventLike, "key" | "code">): string {
const layoutKey = normalizeEventKey(event.key);
if (/^[a-z]$/.test(layoutKey)) return layoutKey;
const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
return physicalKey ?? layoutKey;
}

export function resolveEventKeys(event: ShortcutEventLike): Set<string> {
const layoutKey = normalizeEventKey(event.key);
const keys = new Set([layoutKey]);
const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
if (letterCode && !/^[a-z]$/.test(layoutKey)) {
keys.add(letterCode.toLowerCase());
}
keys.add(shortcutKeyFromEvent(event));
return keys;
}

export function matchesKeybindingShortcutModifiers(
event: ShortcutModifierStateLike,
shortcut: KeybindingShortcut,
platform: string,
): boolean {
const useMetaForMod = isMacPlatform(platform);
const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
return (
event.metaKey === expectedMeta &&
event.ctrlKey === expectedCtrl &&
event.shiftKey === shortcut.shiftKey &&
event.altKey === shortcut.altKey
);
}

export function matchesKeybindingShortcut(
event: ShortcutEventLike,
shortcut: KeybindingShortcut,
platform: string,
): boolean {
if (
!isMacPlatform(platform) &&
event.getModifierState?.("AltGraph") &&
!/^[a-z0-9]$/i.test(event.key)
)
return false;
if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false;
return resolveEventKeys(event).has(shortcut.key);
}

type WhenToken =
| { type: "identifier"; value: string }
| { type: "not" }
Expand Down
1 change: 0 additions & 1 deletion scripts/sync-resolve-deleted.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,6 @@ const DELETED_PATHS = {
"apps/web/src/components/pullRequest/PullRequestReviewForm.browser.test.tsx",
"packages/contracts/src/speech.ts",
/^packages\/shared\/src\/speech\./,
"packages/shared/src/keybindingMatching.ts",
"docs/user/voice-input.md",
"docs/internals/voice-input.md",
"evals/voice-cleanup/",
Expand Down