feat(terminal): refactor runtime and add mobile workspace (#2280)

Replace the legacy terminal flow with a shared authenticated WebSocket
runtime used across web, desktop, relay, and mobile surfaces.

- introduce the v3 terminal protocol with scoped attachments, snapshots,
  ordered output, bounded replay history, reconnects, and explicit lifecycle
- harden PTY creation, restart, resize, close, force-kill, idle cleanup,
  shell selection, login mode, environment sanitization, and appearance sync
- add runtime-aware terminal APIs with relay authentication and Electron parity
- add a fullscreen mobile terminal workspace with touch scrolling,
  long-press selection, safe-area controls, quick keys, and Ctrl/Alt input
- add terminal selection attachments, preview detection, project actions,
  shell settings, and localized UI
- harden Ghostty rendering, resize recovery, Unicode handling, block
  characters, line height, and stale-row behavior
- remove the obsolete terminal SSE path and update reverse-proxy guidance
- expand terminal runtime, transport, input, selection, and store coverage
- avoid duplicate web builds when preparing mobile assets in root CI builds
This commit is contained in:
Bohdan Triapitsyn
2026-07-17 13:17:21 +03:00
committed by GitHub
parent f5b4a267c0
commit d4a8c4d2e1
103 changed files with 4085 additions and 4496 deletions
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal';
export interface InlineCommentDraft {
id: string;
@@ -29,6 +29,7 @@ interface InlineCommentDraftActions {
clearDrafts: (sessionKey: string) => void;
getDrafts: (sessionKey: string) => InlineCommentDraft[];
consumeDrafts: (sessionKey: string) => InlineCommentDraft[];
restoreDrafts: (sessionKey: string, drafts: InlineCommentDraft[]) => void;
getDraftCount: (sessionKey: string) => number;
hasDrafts: (sessionKey: string) => boolean;
}
@@ -36,7 +37,7 @@ interface InlineCommentDraftActions {
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
const isValidSource = (value: unknown): value is InlineCommentSource =>
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation';
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation' || value === 'terminal';
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
value === 'original' || value === 'modified';
@@ -62,6 +63,8 @@ const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
? draft.id
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const code = typeof draft.code === 'string' ? draft.code : '';
if (draft.source === 'terminal' && !code.trim()) return null;
return {
id,
sessionKey: draft.sessionKey,
@@ -70,7 +73,7 @@ const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
startLine,
endLine,
side: isValidSide(draft.side) ? draft.side : undefined,
code: typeof draft.code === 'string' ? draft.code : '',
code,
language: typeof draft.language === 'string' ? draft.language : 'text',
text: typeof draft.text === 'string' ? draft.text : '',
createdAt: Number.isFinite(draft.createdAt) ? Number(draft.createdAt) : Date.now(),
@@ -106,6 +109,7 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
drafts: {},
addDraft: (draft) => {
if (draft.source === 'terminal' && !draft.code.trim()) return;
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const newDraft: InlineCommentDraft = {
...draft,
@@ -115,6 +119,9 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
set((state) => {
const currentDrafts = state.drafts[draft.sessionKey] ?? [];
if (draft.source === 'terminal' && currentDrafts.some((current) => current.source === 'terminal' && current.fileLabel === draft.fileLabel && current.startLine === draft.startLine && current.endLine === draft.endLine && current.code === draft.code)) {
return state;
}
return {
drafts: {
...state.drafts,
@@ -197,6 +204,22 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
return sortedDrafts;
},
restoreDrafts: (sessionKey, drafts) => {
if (drafts.length === 0) return;
set((state) => {
const current = state.drafts[sessionKey] ?? [];
const currentIds = new Set(current.map((draft) => draft.id));
const restored = drafts.filter((draft) => draft.sessionKey === sessionKey && !currentIds.has(draft.id));
if (restored.length === 0) return state;
return {
drafts: {
...state.drafts,
[sessionKey]: [...restored, ...current].sort((a, b) => a.createdAt - b.createdAt),
},
};
});
},
getDraftCount: (sessionKey) => {
return (get().drafts[sessionKey] ?? []).length;
},