@jonsoc/app 1.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +30 -0
- package/README.md +51 -0
- package/bunfig.toml +2 -0
- package/e2e/context.spec.ts +45 -0
- package/e2e/file-open.spec.ts +23 -0
- package/e2e/file-viewer.spec.ts +35 -0
- package/e2e/fixtures.ts +40 -0
- package/e2e/home.spec.ts +21 -0
- package/e2e/model-picker.spec.ts +43 -0
- package/e2e/navigation.spec.ts +9 -0
- package/e2e/palette.spec.ts +15 -0
- package/e2e/prompt-mention.spec.ts +26 -0
- package/e2e/prompt-slash-open.spec.ts +22 -0
- package/e2e/prompt.spec.ts +62 -0
- package/e2e/session.spec.ts +21 -0
- package/e2e/settings.spec.ts +44 -0
- package/e2e/sidebar.spec.ts +21 -0
- package/e2e/terminal-init.spec.ts +25 -0
- package/e2e/terminal.spec.ts +16 -0
- package/e2e/tsconfig.json +8 -0
- package/e2e/utils.ts +38 -0
- package/happydom.ts +75 -0
- package/index.html +23 -0
- package/package.json +72 -0
- package/playwright.config.ts +43 -0
- package/public/_headers +17 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/oc-theme-preload.js +28 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/script/e2e-local.ts +143 -0
- package/src/addons/serialize.test.ts +319 -0
- package/src/addons/serialize.ts +591 -0
- package/src/app.tsx +150 -0
- package/src/components/dialog-connect-provider.tsx +428 -0
- package/src/components/dialog-edit-project.tsx +259 -0
- package/src/components/dialog-fork.tsx +104 -0
- package/src/components/dialog-manage-models.tsx +59 -0
- package/src/components/dialog-select-directory.tsx +208 -0
- package/src/components/dialog-select-file.tsx +196 -0
- package/src/components/dialog-select-mcp.tsx +96 -0
- package/src/components/dialog-select-model-unpaid.tsx +130 -0
- package/src/components/dialog-select-model.tsx +162 -0
- package/src/components/dialog-select-provider.tsx +70 -0
- package/src/components/dialog-select-server.tsx +249 -0
- package/src/components/dialog-settings.tsx +112 -0
- package/src/components/file-tree.tsx +112 -0
- package/src/components/link.tsx +17 -0
- package/src/components/model-tooltip.tsx +91 -0
- package/src/components/prompt-input.tsx +2076 -0
- package/src/components/session/index.ts +5 -0
- package/src/components/session/session-context-tab.tsx +428 -0
- package/src/components/session/session-header.tsx +343 -0
- package/src/components/session/session-new-view.tsx +93 -0
- package/src/components/session/session-sortable-tab.tsx +56 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
- package/src/components/session-context-usage.tsx +113 -0
- package/src/components/session-lsp-indicator.tsx +42 -0
- package/src/components/session-mcp-indicator.tsx +34 -0
- package/src/components/settings-agents.tsx +15 -0
- package/src/components/settings-commands.tsx +15 -0
- package/src/components/settings-general.tsx +306 -0
- package/src/components/settings-keybinds.tsx +437 -0
- package/src/components/settings-mcp.tsx +15 -0
- package/src/components/settings-models.tsx +15 -0
- package/src/components/settings-permissions.tsx +234 -0
- package/src/components/settings-providers.tsx +15 -0
- package/src/components/terminal.tsx +315 -0
- package/src/components/titlebar.tsx +156 -0
- package/src/context/command.tsx +308 -0
- package/src/context/comments.tsx +140 -0
- package/src/context/file.tsx +409 -0
- package/src/context/global-sdk.tsx +106 -0
- package/src/context/global-sync.tsx +898 -0
- package/src/context/language.tsx +161 -0
- package/src/context/layout-scroll.test.ts +73 -0
- package/src/context/layout-scroll.ts +118 -0
- package/src/context/layout.tsx +648 -0
- package/src/context/local.tsx +578 -0
- package/src/context/notification.tsx +173 -0
- package/src/context/permission.tsx +167 -0
- package/src/context/platform.tsx +59 -0
- package/src/context/prompt.tsx +245 -0
- package/src/context/sdk.tsx +48 -0
- package/src/context/server.tsx +214 -0
- package/src/context/settings.tsx +166 -0
- package/src/context/sync.tsx +320 -0
- package/src/context/terminal.tsx +267 -0
- package/src/custom-elements.d.ts +17 -0
- package/src/entry.tsx +76 -0
- package/src/env.d.ts +8 -0
- package/src/hooks/use-providers.ts +31 -0
- package/src/i18n/ar.ts +656 -0
- package/src/i18n/br.ts +667 -0
- package/src/i18n/da.ts +582 -0
- package/src/i18n/de.ts +591 -0
- package/src/i18n/en.ts +665 -0
- package/src/i18n/es.ts +585 -0
- package/src/i18n/fr.ts +592 -0
- package/src/i18n/ja.ts +579 -0
- package/src/i18n/ko.ts +580 -0
- package/src/i18n/no.ts +602 -0
- package/src/i18n/pl.ts +661 -0
- package/src/i18n/ru.ts +664 -0
- package/src/i18n/zh.ts +574 -0
- package/src/i18n/zht.ts +570 -0
- package/src/index.css +57 -0
- package/src/index.ts +2 -0
- package/src/pages/directory-layout.tsx +57 -0
- package/src/pages/error.tsx +290 -0
- package/src/pages/home.tsx +125 -0
- package/src/pages/layout.tsx +2599 -0
- package/src/pages/session.tsx +2505 -0
- package/src/sst-env.d.ts +10 -0
- package/src/utils/dom.ts +51 -0
- package/src/utils/id.ts +99 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/perf.ts +135 -0
- package/src/utils/persist.ts +377 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/solid-dnd.tsx +55 -0
- package/src/utils/sound.ts +110 -0
- package/src/utils/speech.ts +302 -0
- package/src/utils/worktree.ts +58 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +15 -0
- package/vite.js +26 -0
|
@@ -0,0 +1,2505 @@
|
|
|
1
|
+
import {
|
|
2
|
+
For,
|
|
3
|
+
Index,
|
|
4
|
+
onCleanup,
|
|
5
|
+
onMount,
|
|
6
|
+
Show,
|
|
7
|
+
Match,
|
|
8
|
+
Switch,
|
|
9
|
+
createMemo,
|
|
10
|
+
createEffect,
|
|
11
|
+
on,
|
|
12
|
+
createSignal,
|
|
13
|
+
} from "solid-js"
|
|
14
|
+
import { createMediaQuery } from "@solid-primitives/media"
|
|
15
|
+
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
16
|
+
import { Dynamic } from "solid-js/web"
|
|
17
|
+
import { useLocal } from "@/context/local"
|
|
18
|
+
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
|
19
|
+
import { createStore } from "solid-js/store"
|
|
20
|
+
import { PromptInput } from "@/components/prompt-input"
|
|
21
|
+
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
22
|
+
import { IconButton } from "@jonsoc/ui/icon-button"
|
|
23
|
+
import { Button } from "@jonsoc/ui/button"
|
|
24
|
+
import { Icon } from "@jonsoc/ui/icon"
|
|
25
|
+
import { Tooltip, TooltipKeybind } from "@jonsoc/ui/tooltip"
|
|
26
|
+
import { DiffChanges } from "@jonsoc/ui/diff-changes"
|
|
27
|
+
import { ResizeHandle } from "@jonsoc/ui/resize-handle"
|
|
28
|
+
import { Tabs } from "@jonsoc/ui/tabs"
|
|
29
|
+
import { useCodeComponent } from "@jonsoc/ui/context/code"
|
|
30
|
+
import { SessionTurn } from "@jonsoc/ui/session-turn"
|
|
31
|
+
import { createAutoScroll } from "@jonsoc/ui/hooks"
|
|
32
|
+
import { SessionReview } from "@jonsoc/ui/session-review"
|
|
33
|
+
import { Mark } from "@jonsoc/ui/logo"
|
|
34
|
+
import { getFiletypeFromFileName } from "@pierre/diffs"
|
|
35
|
+
|
|
36
|
+
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
|
37
|
+
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
|
38
|
+
import { useSync } from "@/context/sync"
|
|
39
|
+
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
|
40
|
+
import { useLayout } from "@/context/layout"
|
|
41
|
+
import { Terminal } from "@/components/terminal"
|
|
42
|
+
import { checksum, base64Encode, base64Decode } from "@jonsoc/util/encode"
|
|
43
|
+
import { getFilename } from "@jonsoc/util/path"
|
|
44
|
+
import { useDialog } from "@jonsoc/ui/context/dialog"
|
|
45
|
+
import { DialogSelectFile } from "@/components/dialog-select-file"
|
|
46
|
+
import { DialogSelectModel } from "@/components/dialog-select-model"
|
|
47
|
+
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
|
48
|
+
import { DialogFork } from "@/components/dialog-fork"
|
|
49
|
+
import { useCommand } from "@/context/command"
|
|
50
|
+
import { useLanguage } from "@/context/language"
|
|
51
|
+
import { useNavigate, useParams } from "@solidjs/router"
|
|
52
|
+
import { UserMessage } from "@jonsoc/sdk/v2"
|
|
53
|
+
import type { FileDiff } from "@jonsoc/sdk/v2/client"
|
|
54
|
+
import { useSDK } from "@/context/sdk"
|
|
55
|
+
import { usePrompt } from "@/context/prompt"
|
|
56
|
+
import { useComments, type LineComment } from "@/context/comments"
|
|
57
|
+
import { extractPromptFromParts } from "@/utils/prompt"
|
|
58
|
+
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
|
59
|
+
import { usePermission } from "@/context/permission"
|
|
60
|
+
import { showToast } from "@jonsoc/ui/toast"
|
|
61
|
+
import {
|
|
62
|
+
SessionHeader,
|
|
63
|
+
SessionContextTab,
|
|
64
|
+
SortableTab,
|
|
65
|
+
FileVisual,
|
|
66
|
+
SortableTerminalTab,
|
|
67
|
+
NewSessionView,
|
|
68
|
+
} from "@/components/session"
|
|
69
|
+
import { usePlatform } from "@/context/platform"
|
|
70
|
+
import { navMark, navParams } from "@/utils/perf"
|
|
71
|
+
import { same } from "@/utils/same"
|
|
72
|
+
|
|
73
|
+
type DiffStyle = "unified" | "split"
|
|
74
|
+
|
|
75
|
+
const handoff = {
|
|
76
|
+
prompt: "",
|
|
77
|
+
terminals: [] as string[],
|
|
78
|
+
files: {} as Record<string, SelectedLineRange | null>,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface SessionReviewTabProps {
|
|
82
|
+
diffs: () => FileDiff[]
|
|
83
|
+
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
|
84
|
+
diffStyle: DiffStyle
|
|
85
|
+
onDiffStyleChange?: (style: DiffStyle) => void
|
|
86
|
+
onViewFile?: (file: string) => void
|
|
87
|
+
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
|
|
88
|
+
comments?: LineComment[]
|
|
89
|
+
focusedComment?: { file: string; id: string } | null
|
|
90
|
+
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
|
91
|
+
classes?: {
|
|
92
|
+
root?: string
|
|
93
|
+
header?: string
|
|
94
|
+
container?: string
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function SessionReviewTab(props: SessionReviewTabProps) {
|
|
99
|
+
let scroll: HTMLDivElement | undefined
|
|
100
|
+
let frame: number | undefined
|
|
101
|
+
let pending: { x: number; y: number } | undefined
|
|
102
|
+
|
|
103
|
+
const sdk = useSDK()
|
|
104
|
+
|
|
105
|
+
const readFile = (path: string) => {
|
|
106
|
+
return sdk.client.file
|
|
107
|
+
.read({ path })
|
|
108
|
+
.then((x) => x.data)
|
|
109
|
+
.catch(() => undefined)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const restoreScroll = () => {
|
|
113
|
+
const el = scroll
|
|
114
|
+
if (!el) return
|
|
115
|
+
|
|
116
|
+
const s = props.view().scroll("review")
|
|
117
|
+
if (!s) return
|
|
118
|
+
|
|
119
|
+
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
|
120
|
+
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
124
|
+
pending = {
|
|
125
|
+
x: event.currentTarget.scrollLeft,
|
|
126
|
+
y: event.currentTarget.scrollTop,
|
|
127
|
+
}
|
|
128
|
+
if (frame !== undefined) return
|
|
129
|
+
|
|
130
|
+
frame = requestAnimationFrame(() => {
|
|
131
|
+
frame = undefined
|
|
132
|
+
|
|
133
|
+
const next = pending
|
|
134
|
+
pending = undefined
|
|
135
|
+
if (!next) return
|
|
136
|
+
|
|
137
|
+
props.view().setScroll("review", next)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
createEffect(
|
|
142
|
+
on(
|
|
143
|
+
() => props.diffs().length,
|
|
144
|
+
() => {
|
|
145
|
+
requestAnimationFrame(restoreScroll)
|
|
146
|
+
},
|
|
147
|
+
{ defer: true },
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
onCleanup(() => {
|
|
152
|
+
if (frame === undefined) return
|
|
153
|
+
cancelAnimationFrame(frame)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<SessionReview
|
|
158
|
+
scrollRef={(el) => {
|
|
159
|
+
scroll = el
|
|
160
|
+
restoreScroll()
|
|
161
|
+
}}
|
|
162
|
+
onScroll={handleScroll}
|
|
163
|
+
onDiffRendered={() => requestAnimationFrame(restoreScroll)}
|
|
164
|
+
open={props.view().review.open()}
|
|
165
|
+
onOpenChange={props.view().review.setOpen}
|
|
166
|
+
classes={{
|
|
167
|
+
root: props.classes?.root ?? "pb-40",
|
|
168
|
+
header: props.classes?.header ?? "px-6",
|
|
169
|
+
container: props.classes?.container ?? "px-6",
|
|
170
|
+
}}
|
|
171
|
+
diffs={props.diffs()}
|
|
172
|
+
diffStyle={props.diffStyle}
|
|
173
|
+
onDiffStyleChange={props.onDiffStyleChange}
|
|
174
|
+
onViewFile={props.onViewFile}
|
|
175
|
+
readFile={readFile}
|
|
176
|
+
onLineComment={props.onLineComment}
|
|
177
|
+
comments={props.comments}
|
|
178
|
+
focusedComment={props.focusedComment}
|
|
179
|
+
onFocusedCommentChange={props.onFocusedCommentChange}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default function Page() {
|
|
185
|
+
const layout = useLayout()
|
|
186
|
+
const local = useLocal()
|
|
187
|
+
const file = useFile()
|
|
188
|
+
const sync = useSync()
|
|
189
|
+
const terminal = useTerminal()
|
|
190
|
+
const dialog = useDialog()
|
|
191
|
+
const codeComponent = useCodeComponent()
|
|
192
|
+
const command = useCommand()
|
|
193
|
+
const language = useLanguage()
|
|
194
|
+
const platform = usePlatform()
|
|
195
|
+
const params = useParams()
|
|
196
|
+
const navigate = useNavigate()
|
|
197
|
+
const sdk = useSDK()
|
|
198
|
+
const prompt = usePrompt()
|
|
199
|
+
const comments = useComments()
|
|
200
|
+
const permission = usePermission()
|
|
201
|
+
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
|
|
202
|
+
const dirKey = createMemo(() => params.dir ?? "")
|
|
203
|
+
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
204
|
+
const tabs = createMemo(() => layout.tabs(dirKey))
|
|
205
|
+
const view = createMemo(() => layout.view(sessionKey))
|
|
206
|
+
|
|
207
|
+
if (import.meta.env.DEV) {
|
|
208
|
+
createEffect(
|
|
209
|
+
on(
|
|
210
|
+
() => [params.dir, params.id] as const,
|
|
211
|
+
([dir, id], prev) => {
|
|
212
|
+
if (!id) return
|
|
213
|
+
navParams({ dir, from: prev?.[1], to: id })
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
createEffect(() => {
|
|
219
|
+
const id = params.id
|
|
220
|
+
if (!id) return
|
|
221
|
+
if (!prompt.ready()) return
|
|
222
|
+
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
createEffect(() => {
|
|
226
|
+
const id = params.id
|
|
227
|
+
if (!id) return
|
|
228
|
+
if (!terminal.ready()) return
|
|
229
|
+
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
createEffect(() => {
|
|
233
|
+
const id = params.id
|
|
234
|
+
if (!id) return
|
|
235
|
+
if (!file.ready()) return
|
|
236
|
+
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
createEffect(() => {
|
|
240
|
+
const id = params.id
|
|
241
|
+
if (!id) return
|
|
242
|
+
if (sync.data.message[id] === undefined) return
|
|
243
|
+
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const isDesktop = createMediaQuery("(min-width: 768px)")
|
|
248
|
+
|
|
249
|
+
function normalizeTab(tab: string) {
|
|
250
|
+
if (!tab.startsWith("file://")) return tab
|
|
251
|
+
return file.tab(tab)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normalizeTabs(list: string[]) {
|
|
255
|
+
const seen = new Set<string>()
|
|
256
|
+
const next: string[] = []
|
|
257
|
+
for (const item of list) {
|
|
258
|
+
const value = normalizeTab(item)
|
|
259
|
+
if (seen.has(value)) continue
|
|
260
|
+
seen.add(value)
|
|
261
|
+
next.push(value)
|
|
262
|
+
}
|
|
263
|
+
return next
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const openTab = (value: string) => {
|
|
267
|
+
const next = normalizeTab(value)
|
|
268
|
+
tabs().open(next)
|
|
269
|
+
|
|
270
|
+
const path = file.pathFromTab(next)
|
|
271
|
+
if (path) file.load(path)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
createEffect(() => {
|
|
275
|
+
const active = tabs().active()
|
|
276
|
+
if (!active) return
|
|
277
|
+
|
|
278
|
+
const path = file.pathFromTab(active)
|
|
279
|
+
if (path) file.load(path)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
createEffect(() => {
|
|
283
|
+
const current = tabs().all()
|
|
284
|
+
if (current.length === 0) return
|
|
285
|
+
|
|
286
|
+
const next = normalizeTabs(current)
|
|
287
|
+
if (same(current, next)) return
|
|
288
|
+
|
|
289
|
+
tabs().setAll(next)
|
|
290
|
+
|
|
291
|
+
const active = tabs().active()
|
|
292
|
+
if (!active) return
|
|
293
|
+
if (!active.startsWith("file://")) return
|
|
294
|
+
|
|
295
|
+
const normalized = normalizeTab(active)
|
|
296
|
+
if (active === normalized) return
|
|
297
|
+
tabs().setActive(normalized)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
|
301
|
+
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
|
|
302
|
+
const hasReview = createMemo(() => reviewCount() > 0)
|
|
303
|
+
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
|
304
|
+
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
|
305
|
+
const messagesReady = createMemo(() => {
|
|
306
|
+
const id = params.id
|
|
307
|
+
if (!id) return true
|
|
308
|
+
return sync.data.message[id] !== undefined
|
|
309
|
+
})
|
|
310
|
+
const historyMore = createMemo(() => {
|
|
311
|
+
const id = params.id
|
|
312
|
+
if (!id) return false
|
|
313
|
+
return sync.session.history.more(id)
|
|
314
|
+
})
|
|
315
|
+
const historyLoading = createMemo(() => {
|
|
316
|
+
const id = params.id
|
|
317
|
+
if (!id) return false
|
|
318
|
+
return sync.session.history.loading(id)
|
|
319
|
+
})
|
|
320
|
+
const emptyUserMessages: UserMessage[] = []
|
|
321
|
+
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
|
322
|
+
const visibleUserMessages = createMemo(() => {
|
|
323
|
+
const revert = revertMessageID()
|
|
324
|
+
if (!revert) return userMessages()
|
|
325
|
+
return userMessages().filter((m) => m.id < revert)
|
|
326
|
+
}, emptyUserMessages)
|
|
327
|
+
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
|
328
|
+
|
|
329
|
+
createEffect(
|
|
330
|
+
on(
|
|
331
|
+
() => lastUserMessage()?.id,
|
|
332
|
+
() => {
|
|
333
|
+
const msg = lastUserMessage()
|
|
334
|
+
if (!msg) return
|
|
335
|
+
if (msg.agent) local.agent.set(msg.agent)
|
|
336
|
+
if (msg.model) local.model.set(msg.model)
|
|
337
|
+
},
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
const [store, setStore] = createStore({
|
|
342
|
+
activeDraggable: undefined as string | undefined,
|
|
343
|
+
activeTerminalDraggable: undefined as string | undefined,
|
|
344
|
+
expanded: {} as Record<string, boolean>,
|
|
345
|
+
messageId: undefined as string | undefined,
|
|
346
|
+
turnStart: 0,
|
|
347
|
+
mobileTab: "session" as "session" | "review",
|
|
348
|
+
newSessionWorktree: "main",
|
|
349
|
+
promptHeight: 0,
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const renderedUserMessages = createMemo(() => {
|
|
353
|
+
const msgs = visibleUserMessages()
|
|
354
|
+
const start = store.turnStart
|
|
355
|
+
if (start <= 0) return msgs
|
|
356
|
+
if (start >= msgs.length) return emptyUserMessages
|
|
357
|
+
return msgs.slice(start)
|
|
358
|
+
}, emptyUserMessages)
|
|
359
|
+
|
|
360
|
+
const newSessionWorktree = createMemo(() => {
|
|
361
|
+
if (store.newSessionWorktree === "create") return "create"
|
|
362
|
+
const project = sync.project
|
|
363
|
+
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
|
|
364
|
+
return "main"
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const activeMessage = createMemo(() => {
|
|
368
|
+
if (!store.messageId) return lastUserMessage()
|
|
369
|
+
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
|
370
|
+
return found ?? lastUserMessage()
|
|
371
|
+
})
|
|
372
|
+
const setActiveMessage = (message: UserMessage | undefined) => {
|
|
373
|
+
setStore("messageId", message?.id)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function navigateMessageByOffset(offset: number) {
|
|
377
|
+
const msgs = visibleUserMessages()
|
|
378
|
+
if (msgs.length === 0) return
|
|
379
|
+
|
|
380
|
+
const current = activeMessage()
|
|
381
|
+
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
|
|
382
|
+
const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
|
|
383
|
+
if (targetIndex < 0 || targetIndex >= msgs.length) return
|
|
384
|
+
|
|
385
|
+
scrollToMessage(msgs[targetIndex], "auto")
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
|
389
|
+
const diffsReady = createMemo(() => {
|
|
390
|
+
const id = params.id
|
|
391
|
+
if (!id) return true
|
|
392
|
+
if (!hasReview()) return true
|
|
393
|
+
return sync.data.session_diff[id] !== undefined
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const idle = { type: "idle" as const }
|
|
397
|
+
let inputRef!: HTMLDivElement
|
|
398
|
+
let promptDock: HTMLDivElement | undefined
|
|
399
|
+
let scroller: HTMLDivElement | undefined
|
|
400
|
+
|
|
401
|
+
const [scrollGesture, setScrollGesture] = createSignal(0)
|
|
402
|
+
const scrollGestureWindowMs = 250
|
|
403
|
+
|
|
404
|
+
const markScrollGesture = (target?: EventTarget | null) => {
|
|
405
|
+
const root = scroller
|
|
406
|
+
if (!root) return
|
|
407
|
+
|
|
408
|
+
const el = target instanceof Element ? target : undefined
|
|
409
|
+
const nested = el?.closest("[data-scrollable]")
|
|
410
|
+
if (nested && nested !== root) return
|
|
411
|
+
|
|
412
|
+
setScrollGesture(Date.now())
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs
|
|
416
|
+
|
|
417
|
+
createEffect(() => {
|
|
418
|
+
if (!params.id) return
|
|
419
|
+
sync.session.sync(params.id)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const [autoCreated, setAutoCreated] = createSignal(false)
|
|
423
|
+
|
|
424
|
+
createEffect(() => {
|
|
425
|
+
if (!view().terminal.opened()) {
|
|
426
|
+
setAutoCreated(false)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return
|
|
430
|
+
terminal.new()
|
|
431
|
+
setAutoCreated(true)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
createEffect(
|
|
435
|
+
on(
|
|
436
|
+
() => terminal.all().length,
|
|
437
|
+
(count, prevCount) => {
|
|
438
|
+
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
|
439
|
+
if (view().terminal.opened()) {
|
|
440
|
+
view().terminal.toggle()
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
createEffect(
|
|
448
|
+
on(
|
|
449
|
+
() => terminal.active(),
|
|
450
|
+
(activeId) => {
|
|
451
|
+
if (!activeId || !view().terminal.opened()) return
|
|
452
|
+
// Immediately remove focus
|
|
453
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
454
|
+
document.activeElement.blur()
|
|
455
|
+
}
|
|
456
|
+
const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
|
|
457
|
+
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
|
458
|
+
if (!element) return
|
|
459
|
+
|
|
460
|
+
// Find and focus the ghostty textarea (the actual input element)
|
|
461
|
+
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
|
462
|
+
if (textarea) {
|
|
463
|
+
textarea.focus()
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
// Fallback: focus container and dispatch pointer event
|
|
467
|
+
element.focus()
|
|
468
|
+
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
|
469
|
+
},
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
createEffect(
|
|
474
|
+
on(
|
|
475
|
+
() => visibleUserMessages().at(-1)?.id,
|
|
476
|
+
(lastId, prevLastId) => {
|
|
477
|
+
if (lastId && prevLastId && lastId > prevLastId) {
|
|
478
|
+
setStore("messageId", undefined)
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
{ defer: true },
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
|
486
|
+
|
|
487
|
+
createEffect(
|
|
488
|
+
on(
|
|
489
|
+
() => params.id,
|
|
490
|
+
() => {
|
|
491
|
+
setStore("messageId", undefined)
|
|
492
|
+
setStore("expanded", {})
|
|
493
|
+
},
|
|
494
|
+
{ defer: true },
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
createEffect(() => {
|
|
499
|
+
const id = lastUserMessage()?.id
|
|
500
|
+
if (!id) return
|
|
501
|
+
setStore("expanded", id, status().type !== "idle")
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const selectionPreview = (path: string, selection: FileSelection) => {
|
|
505
|
+
const content = file.get(path)?.content?.content
|
|
506
|
+
if (!content) return undefined
|
|
507
|
+
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
|
508
|
+
const end = Math.max(selection.startLine, selection.endLine)
|
|
509
|
+
const lines = content.split("\n").slice(start - 1, end)
|
|
510
|
+
if (lines.length === 0) return undefined
|
|
511
|
+
return lines.slice(0, 2).join("\n")
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
|
515
|
+
const preview = selectionPreview(path, selection)
|
|
516
|
+
prompt.context.add({ type: "file", path, selection, preview })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const addCommentToContext = (input: {
|
|
520
|
+
file: string
|
|
521
|
+
selection: SelectedLineRange
|
|
522
|
+
comment: string
|
|
523
|
+
preview?: string
|
|
524
|
+
}) => {
|
|
525
|
+
const selection = selectionFromLines(input.selection)
|
|
526
|
+
const preview = input.preview ?? selectionPreview(input.file, selection)
|
|
527
|
+
const saved = comments.add({
|
|
528
|
+
file: input.file,
|
|
529
|
+
selection: input.selection,
|
|
530
|
+
comment: input.comment,
|
|
531
|
+
})
|
|
532
|
+
prompt.context.add({
|
|
533
|
+
type: "file",
|
|
534
|
+
path: input.file,
|
|
535
|
+
selection,
|
|
536
|
+
comment: input.comment,
|
|
537
|
+
commentID: saved.id,
|
|
538
|
+
preview,
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
command.register(() => [
|
|
543
|
+
{
|
|
544
|
+
id: "session.new",
|
|
545
|
+
title: "New session",
|
|
546
|
+
category: "Session",
|
|
547
|
+
keybind: "mod+shift+s",
|
|
548
|
+
slash: "new",
|
|
549
|
+
onSelect: () => navigate(`/${params.dir}/session`),
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
id: "file.open",
|
|
553
|
+
title: "Open file",
|
|
554
|
+
description: "Search files and commands",
|
|
555
|
+
category: "File",
|
|
556
|
+
keybind: "mod+p",
|
|
557
|
+
slash: "open",
|
|
558
|
+
onSelect: () => dialog.show(() => <DialogSelectFile />),
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: "context.addSelection",
|
|
562
|
+
title: "Add selection to context",
|
|
563
|
+
description: "Add selected lines from the current file",
|
|
564
|
+
category: "Context",
|
|
565
|
+
keybind: "mod+shift+l",
|
|
566
|
+
disabled: (() => {
|
|
567
|
+
const active = tabs().active()
|
|
568
|
+
if (!active) return true
|
|
569
|
+
const path = file.pathFromTab(active)
|
|
570
|
+
if (!path) return true
|
|
571
|
+
return file.selectedLines(path) == null
|
|
572
|
+
})(),
|
|
573
|
+
onSelect: () => {
|
|
574
|
+
const active = tabs().active()
|
|
575
|
+
if (!active) return
|
|
576
|
+
const path = file.pathFromTab(active)
|
|
577
|
+
if (!path) return
|
|
578
|
+
|
|
579
|
+
const range = file.selectedLines(path)
|
|
580
|
+
if (!range) {
|
|
581
|
+
showToast({
|
|
582
|
+
title: "No line selection",
|
|
583
|
+
description: "Select a line range in a file tab first.",
|
|
584
|
+
})
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
addSelectionToContext(path, selectionFromLines(range))
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
id: "terminal.toggle",
|
|
593
|
+
title: "Toggle terminal",
|
|
594
|
+
description: "",
|
|
595
|
+
category: "View",
|
|
596
|
+
keybind: "ctrl+`",
|
|
597
|
+
slash: "terminal",
|
|
598
|
+
onSelect: () => view().terminal.toggle(),
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
id: "review.toggle",
|
|
602
|
+
title: "Toggle review",
|
|
603
|
+
description: "",
|
|
604
|
+
category: "View",
|
|
605
|
+
keybind: "mod+shift+r",
|
|
606
|
+
onSelect: () => view().reviewPanel.toggle(),
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
id: "terminal.new",
|
|
610
|
+
title: language.t("command.terminal.new"),
|
|
611
|
+
description: language.t("command.terminal.new.description"),
|
|
612
|
+
category: language.t("command.category.terminal"),
|
|
613
|
+
keybind: "ctrl+alt+t",
|
|
614
|
+
onSelect: () => {
|
|
615
|
+
if (terminal.all().length > 0) terminal.new()
|
|
616
|
+
view().terminal.open()
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: "steps.toggle",
|
|
621
|
+
title: "Toggle steps",
|
|
622
|
+
description: "Show or hide steps for the current message",
|
|
623
|
+
category: "View",
|
|
624
|
+
keybind: "mod+e",
|
|
625
|
+
slash: "steps",
|
|
626
|
+
disabled: !params.id,
|
|
627
|
+
onSelect: () => {
|
|
628
|
+
const msg = activeMessage()
|
|
629
|
+
if (!msg) return
|
|
630
|
+
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
id: "message.previous",
|
|
635
|
+
title: "Previous message",
|
|
636
|
+
description: "Go to the previous user message",
|
|
637
|
+
category: "Session",
|
|
638
|
+
keybind: "mod+arrowup",
|
|
639
|
+
disabled: !params.id,
|
|
640
|
+
onSelect: () => navigateMessageByOffset(-1),
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
id: "message.next",
|
|
644
|
+
title: "Next message",
|
|
645
|
+
description: "Go to the next user message",
|
|
646
|
+
category: "Session",
|
|
647
|
+
keybind: "mod+arrowdown",
|
|
648
|
+
disabled: !params.id,
|
|
649
|
+
onSelect: () => navigateMessageByOffset(1),
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
id: "model.choose",
|
|
653
|
+
title: "Choose model",
|
|
654
|
+
description: "Select a different model",
|
|
655
|
+
category: "Model",
|
|
656
|
+
keybind: "mod+'",
|
|
657
|
+
slash: "model",
|
|
658
|
+
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
id: "mcp.toggle",
|
|
662
|
+
title: "Toggle MCPs",
|
|
663
|
+
description: "Toggle MCPs",
|
|
664
|
+
category: "MCP",
|
|
665
|
+
keybind: "mod+;",
|
|
666
|
+
slash: "mcp",
|
|
667
|
+
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
id: "agent.cycle",
|
|
671
|
+
title: "Cycle agent",
|
|
672
|
+
description: "Switch to the next agent",
|
|
673
|
+
category: "Agent",
|
|
674
|
+
keybind: "mod+.",
|
|
675
|
+
slash: "agent",
|
|
676
|
+
onSelect: () => local.agent.move(1),
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
id: "agent.cycle.reverse",
|
|
680
|
+
title: "Cycle agent backwards",
|
|
681
|
+
description: "Switch to the previous agent",
|
|
682
|
+
category: "Agent",
|
|
683
|
+
keybind: "shift+mod+.",
|
|
684
|
+
onSelect: () => local.agent.move(-1),
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
id: "model.variant.cycle",
|
|
688
|
+
title: "Cycle thinking effort",
|
|
689
|
+
description: "Switch to the next effort level",
|
|
690
|
+
category: "Model",
|
|
691
|
+
keybind: "shift+mod+d",
|
|
692
|
+
onSelect: () => {
|
|
693
|
+
local.model.variant.cycle()
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
id: "permissions.autoaccept",
|
|
698
|
+
title:
|
|
699
|
+
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
|
700
|
+
? "Stop auto-accepting edits"
|
|
701
|
+
: "Auto-accept edits",
|
|
702
|
+
category: "Permissions",
|
|
703
|
+
keybind: "mod+shift+a",
|
|
704
|
+
disabled: !params.id || !permission.permissionsEnabled(),
|
|
705
|
+
onSelect: () => {
|
|
706
|
+
const sessionID = params.id
|
|
707
|
+
if (!sessionID) return
|
|
708
|
+
permission.toggleAutoAccept(sessionID, sdk.directory)
|
|
709
|
+
showToast({
|
|
710
|
+
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
|
711
|
+
? "Auto-accepting edits"
|
|
712
|
+
: "Stopped auto-accepting edits",
|
|
713
|
+
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
|
714
|
+
? "Edit and write permissions will be automatically approved"
|
|
715
|
+
: "Edit and write permissions will require approval",
|
|
716
|
+
})
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
id: "session.undo",
|
|
721
|
+
title: "Undo",
|
|
722
|
+
description: "Undo the last message",
|
|
723
|
+
category: "Session",
|
|
724
|
+
slash: "undo",
|
|
725
|
+
disabled: !params.id || visibleUserMessages().length === 0,
|
|
726
|
+
onSelect: async () => {
|
|
727
|
+
const sessionID = params.id
|
|
728
|
+
if (!sessionID) return
|
|
729
|
+
if (status()?.type !== "idle") {
|
|
730
|
+
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
|
731
|
+
}
|
|
732
|
+
const revert = info()?.revert?.messageID
|
|
733
|
+
// Find the last user message that's not already reverted
|
|
734
|
+
const message = userMessages().findLast((x) => !revert || x.id < revert)
|
|
735
|
+
if (!message) return
|
|
736
|
+
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
|
737
|
+
// Restore the prompt from the reverted message
|
|
738
|
+
const parts = sync.data.part[message.id]
|
|
739
|
+
if (parts) {
|
|
740
|
+
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
|
741
|
+
prompt.set(restored)
|
|
742
|
+
}
|
|
743
|
+
// Navigate to the message before the reverted one (which will be the new last visible message)
|
|
744
|
+
const priorMessage = userMessages().findLast((x) => x.id < message.id)
|
|
745
|
+
setActiveMessage(priorMessage)
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
id: "session.redo",
|
|
750
|
+
title: "Redo",
|
|
751
|
+
description: "Redo the last undone message",
|
|
752
|
+
category: "Session",
|
|
753
|
+
slash: "redo",
|
|
754
|
+
disabled: !params.id || !info()?.revert?.messageID,
|
|
755
|
+
onSelect: async () => {
|
|
756
|
+
const sessionID = params.id
|
|
757
|
+
if (!sessionID) return
|
|
758
|
+
const revertMessageID = info()?.revert?.messageID
|
|
759
|
+
if (!revertMessageID) return
|
|
760
|
+
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
|
761
|
+
if (!nextMessage) {
|
|
762
|
+
// Full unrevert - restore all messages and navigate to last
|
|
763
|
+
await sdk.client.session.unrevert({ sessionID })
|
|
764
|
+
prompt.reset()
|
|
765
|
+
// Navigate to the last message (the one that was at the revert point)
|
|
766
|
+
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
|
|
767
|
+
setActiveMessage(lastMsg)
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
// Partial redo - move forward to next message
|
|
771
|
+
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
|
772
|
+
// Navigate to the message before the new revert point
|
|
773
|
+
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
|
|
774
|
+
setActiveMessage(priorMsg)
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
id: "session.compact",
|
|
779
|
+
title: "Compact session",
|
|
780
|
+
description: "Summarize the session to reduce context size",
|
|
781
|
+
category: "Session",
|
|
782
|
+
slash: "compact",
|
|
783
|
+
disabled: !params.id || visibleUserMessages().length === 0,
|
|
784
|
+
onSelect: async () => {
|
|
785
|
+
const sessionID = params.id
|
|
786
|
+
if (!sessionID) return
|
|
787
|
+
const model = local.model.current()
|
|
788
|
+
if (!model) {
|
|
789
|
+
showToast({
|
|
790
|
+
title: "No model selected",
|
|
791
|
+
description: "Connect a provider to summarize this session",
|
|
792
|
+
})
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
await sdk.client.session.summarize({
|
|
796
|
+
sessionID,
|
|
797
|
+
modelID: model.id,
|
|
798
|
+
providerID: model.provider.id,
|
|
799
|
+
})
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
id: "session.fork",
|
|
804
|
+
title: "Fork from message",
|
|
805
|
+
description: "Create a new session from a previous message",
|
|
806
|
+
category: "Session",
|
|
807
|
+
slash: "fork",
|
|
808
|
+
disabled: !params.id || visibleUserMessages().length === 0,
|
|
809
|
+
onSelect: () => dialog.show(() => <DialogFork />),
|
|
810
|
+
},
|
|
811
|
+
...(sync.data.config.share !== "disabled"
|
|
812
|
+
? [
|
|
813
|
+
{
|
|
814
|
+
id: "session.share",
|
|
815
|
+
title: "Share session",
|
|
816
|
+
description: "Share this session and copy the URL to clipboard",
|
|
817
|
+
category: "Session",
|
|
818
|
+
slash: "share",
|
|
819
|
+
disabled: !params.id || !!info()?.share?.url,
|
|
820
|
+
onSelect: async () => {
|
|
821
|
+
if (!params.id) return
|
|
822
|
+
await sdk.client.session
|
|
823
|
+
.share({ sessionID: params.id })
|
|
824
|
+
.then((res) => {
|
|
825
|
+
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
|
|
826
|
+
showToast({
|
|
827
|
+
title: "Failed to copy URL to clipboard",
|
|
828
|
+
variant: "error",
|
|
829
|
+
}),
|
|
830
|
+
)
|
|
831
|
+
})
|
|
832
|
+
.then(() =>
|
|
833
|
+
showToast({
|
|
834
|
+
title: "Session shared",
|
|
835
|
+
description: "Share URL copied to clipboard!",
|
|
836
|
+
variant: "success",
|
|
837
|
+
}),
|
|
838
|
+
)
|
|
839
|
+
.catch(() =>
|
|
840
|
+
showToast({
|
|
841
|
+
title: "Failed to share session",
|
|
842
|
+
description: "An error occurred while sharing the session",
|
|
843
|
+
variant: "error",
|
|
844
|
+
}),
|
|
845
|
+
)
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
id: "session.unshare",
|
|
850
|
+
title: "Unshare session",
|
|
851
|
+
description: "Stop sharing this session",
|
|
852
|
+
category: "Session",
|
|
853
|
+
slash: "unshare",
|
|
854
|
+
disabled: !params.id || !info()?.share?.url,
|
|
855
|
+
onSelect: async () => {
|
|
856
|
+
if (!params.id) return
|
|
857
|
+
await sdk.client.session
|
|
858
|
+
.unshare({ sessionID: params.id })
|
|
859
|
+
.then(() =>
|
|
860
|
+
showToast({
|
|
861
|
+
title: "Session unshared",
|
|
862
|
+
description: "Session unshared successfully!",
|
|
863
|
+
variant: "success",
|
|
864
|
+
}),
|
|
865
|
+
)
|
|
866
|
+
.catch(() =>
|
|
867
|
+
showToast({
|
|
868
|
+
title: "Failed to unshare session",
|
|
869
|
+
description: "An error occurred while unsharing the session",
|
|
870
|
+
variant: "error",
|
|
871
|
+
}),
|
|
872
|
+
)
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
]
|
|
876
|
+
: []),
|
|
877
|
+
])
|
|
878
|
+
|
|
879
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
880
|
+
const activeElement = document.activeElement as HTMLElement | undefined
|
|
881
|
+
if (activeElement) {
|
|
882
|
+
const isProtected = activeElement.closest("[data-prevent-autofocus]")
|
|
883
|
+
const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
|
884
|
+
if (isProtected || isInput) return
|
|
885
|
+
}
|
|
886
|
+
if (dialog.active) return
|
|
887
|
+
|
|
888
|
+
if (activeElement === inputRef) {
|
|
889
|
+
if (event.key === "Escape") inputRef?.blur()
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Don't autofocus chat if terminal panel is open
|
|
894
|
+
if (view().terminal.opened()) return
|
|
895
|
+
|
|
896
|
+
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
|
897
|
+
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
|
898
|
+
markScrollGesture()
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
|
903
|
+
inputRef?.focus()
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const handleDragStart = (event: unknown) => {
|
|
908
|
+
const id = getDraggableId(event)
|
|
909
|
+
if (!id) return
|
|
910
|
+
setStore("activeDraggable", id)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const handleDragOver = (event: DragEvent) => {
|
|
914
|
+
const { draggable, droppable } = event
|
|
915
|
+
if (draggable && droppable) {
|
|
916
|
+
const currentTabs = tabs().all()
|
|
917
|
+
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
|
|
918
|
+
const toIndex = currentTabs?.indexOf(droppable.id.toString())
|
|
919
|
+
if (fromIndex !== toIndex && toIndex !== undefined) {
|
|
920
|
+
tabs().move(draggable.id.toString(), toIndex)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const handleDragEnd = () => {
|
|
926
|
+
setStore("activeDraggable", undefined)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const handleTerminalDragStart = (event: unknown) => {
|
|
930
|
+
const id = getDraggableId(event)
|
|
931
|
+
if (!id) return
|
|
932
|
+
setStore("activeTerminalDraggable", id)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const handleTerminalDragOver = (event: DragEvent) => {
|
|
936
|
+
const { draggable, droppable } = event
|
|
937
|
+
if (draggable && droppable) {
|
|
938
|
+
const terminals = terminal.all()
|
|
939
|
+
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
|
940
|
+
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
|
941
|
+
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
942
|
+
terminal.move(draggable.id.toString(), toIndex)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const handleTerminalDragEnd = () => {
|
|
948
|
+
setStore("activeTerminalDraggable", undefined)
|
|
949
|
+
const activeId = terminal.active()
|
|
950
|
+
if (!activeId) return
|
|
951
|
+
setTimeout(() => {
|
|
952
|
+
const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
|
|
953
|
+
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
|
954
|
+
if (!element) return
|
|
955
|
+
|
|
956
|
+
// Find and focus the ghostty textarea (the actual input element)
|
|
957
|
+
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
|
958
|
+
if (textarea) {
|
|
959
|
+
textarea.focus()
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
// Fallback: focus container and dispatch pointer event
|
|
963
|
+
element.focus()
|
|
964
|
+
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
|
965
|
+
}, 0)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
|
969
|
+
const openedTabs = createMemo(() =>
|
|
970
|
+
tabs()
|
|
971
|
+
.all()
|
|
972
|
+
.filter((tab) => tab !== "context"),
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
const mobileReview = createMemo(() => !isDesktop() && layout.review.panelOpened() && store.mobileTab === "review")
|
|
976
|
+
|
|
977
|
+
const showTabs = createMemo(() => layout.review.panelOpened())
|
|
978
|
+
|
|
979
|
+
const activeTab = createMemo(() => {
|
|
980
|
+
const active = tabs().active()
|
|
981
|
+
if (active) return active
|
|
982
|
+
if (hasReview()) return "review"
|
|
983
|
+
|
|
984
|
+
const first = openedTabs()[0]
|
|
985
|
+
if (first) return first
|
|
986
|
+
if (contextOpen()) return "context"
|
|
987
|
+
return "review"
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
createEffect(() => {
|
|
991
|
+
if (!layout.ready()) return
|
|
992
|
+
if (tabs().active()) return
|
|
993
|
+
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
|
|
994
|
+
tabs().setActive(activeTab())
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
createEffect(() => {
|
|
998
|
+
const id = params.id
|
|
999
|
+
if (!id) return
|
|
1000
|
+
if (!hasReview()) return
|
|
1001
|
+
|
|
1002
|
+
const wants = isDesktop() ? layout.review.panelOpened() && activeTab() === "review" : store.mobileTab === "review"
|
|
1003
|
+
if (!wants) return
|
|
1004
|
+
if (diffsReady()) return
|
|
1005
|
+
|
|
1006
|
+
sync.session.diff(id)
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
const autoScroll = createAutoScroll({
|
|
1010
|
+
working: () => true,
|
|
1011
|
+
overflowAnchor: "dynamic",
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
const resumeScroll = () => {
|
|
1015
|
+
setStore("messageId", undefined)
|
|
1016
|
+
autoScroll.forceScrollToBottom()
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// When the user returns to the bottom, treat the active message as "latest".
|
|
1020
|
+
createEffect(
|
|
1021
|
+
on(
|
|
1022
|
+
autoScroll.userScrolled,
|
|
1023
|
+
(scrolled) => {
|
|
1024
|
+
if (scrolled) return
|
|
1025
|
+
setStore("messageId", undefined)
|
|
1026
|
+
},
|
|
1027
|
+
{ defer: true },
|
|
1028
|
+
),
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
let scrollSpyFrame: number | undefined
|
|
1032
|
+
let scrollSpyTarget: HTMLDivElement | undefined
|
|
1033
|
+
|
|
1034
|
+
const anchor = (id: string) => `message-${id}`
|
|
1035
|
+
|
|
1036
|
+
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
|
1037
|
+
scroller = el
|
|
1038
|
+
autoScroll.scrollRef(el)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const turnInit = 20
|
|
1042
|
+
const turnBatch = 20
|
|
1043
|
+
let turnHandle: number | undefined
|
|
1044
|
+
let turnIdle = false
|
|
1045
|
+
|
|
1046
|
+
function cancelTurnBackfill() {
|
|
1047
|
+
const handle = turnHandle
|
|
1048
|
+
if (handle === undefined) return
|
|
1049
|
+
turnHandle = undefined
|
|
1050
|
+
|
|
1051
|
+
if (turnIdle && window.cancelIdleCallback) {
|
|
1052
|
+
window.cancelIdleCallback(handle)
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
clearTimeout(handle)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function scheduleTurnBackfill() {
|
|
1060
|
+
if (turnHandle !== undefined) return
|
|
1061
|
+
if (store.turnStart <= 0) return
|
|
1062
|
+
|
|
1063
|
+
if (window.requestIdleCallback) {
|
|
1064
|
+
turnIdle = true
|
|
1065
|
+
turnHandle = window.requestIdleCallback(() => {
|
|
1066
|
+
turnHandle = undefined
|
|
1067
|
+
backfillTurns()
|
|
1068
|
+
})
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
turnIdle = false
|
|
1073
|
+
turnHandle = window.setTimeout(() => {
|
|
1074
|
+
turnHandle = undefined
|
|
1075
|
+
backfillTurns()
|
|
1076
|
+
}, 0)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function backfillTurns() {
|
|
1080
|
+
const start = store.turnStart
|
|
1081
|
+
if (start <= 0) return
|
|
1082
|
+
|
|
1083
|
+
const next = start - turnBatch
|
|
1084
|
+
const nextStart = next > 0 ? next : 0
|
|
1085
|
+
|
|
1086
|
+
const el = scroller
|
|
1087
|
+
if (!el) {
|
|
1088
|
+
setStore("turnStart", nextStart)
|
|
1089
|
+
scheduleTurnBackfill()
|
|
1090
|
+
return
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const beforeTop = el.scrollTop
|
|
1094
|
+
const beforeHeight = el.scrollHeight
|
|
1095
|
+
|
|
1096
|
+
setStore("turnStart", nextStart)
|
|
1097
|
+
|
|
1098
|
+
requestAnimationFrame(() => {
|
|
1099
|
+
const delta = el.scrollHeight - beforeHeight
|
|
1100
|
+
if (delta) el.scrollTop = beforeTop + delta
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
scheduleTurnBackfill()
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
createEffect(
|
|
1107
|
+
on(
|
|
1108
|
+
() => [params.id, messagesReady()] as const,
|
|
1109
|
+
([id, ready]) => {
|
|
1110
|
+
cancelTurnBackfill()
|
|
1111
|
+
setStore("turnStart", 0)
|
|
1112
|
+
if (!id || !ready) return
|
|
1113
|
+
|
|
1114
|
+
const len = visibleUserMessages().length
|
|
1115
|
+
const start = len > turnInit ? len - turnInit : 0
|
|
1116
|
+
setStore("turnStart", start)
|
|
1117
|
+
scheduleTurnBackfill()
|
|
1118
|
+
},
|
|
1119
|
+
{ defer: true },
|
|
1120
|
+
),
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
createResizeObserver(
|
|
1124
|
+
() => promptDock,
|
|
1125
|
+
({ height }) => {
|
|
1126
|
+
const next = Math.ceil(height)
|
|
1127
|
+
|
|
1128
|
+
if (next === store.promptHeight) return
|
|
1129
|
+
|
|
1130
|
+
const el = scroller
|
|
1131
|
+
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
|
|
1132
|
+
|
|
1133
|
+
setStore("promptHeight", next)
|
|
1134
|
+
|
|
1135
|
+
if (stick && el) {
|
|
1136
|
+
requestAnimationFrame(() => {
|
|
1137
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
const updateHash = (id: string) => {
|
|
1144
|
+
window.history.replaceState(null, "", `#${anchor(id)}`)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
createEffect(() => {
|
|
1148
|
+
const sessionID = params.id
|
|
1149
|
+
if (!sessionID) return
|
|
1150
|
+
const raw = sessionStorage.getItem("jonsoc.pendingMessage")
|
|
1151
|
+
if (!raw) return
|
|
1152
|
+
const parts = raw.split("|")
|
|
1153
|
+
const pendingSessionID = parts[0]
|
|
1154
|
+
const messageID = parts[1]
|
|
1155
|
+
if (!pendingSessionID || !messageID) return
|
|
1156
|
+
if (pendingSessionID !== sessionID) return
|
|
1157
|
+
|
|
1158
|
+
sessionStorage.removeItem("jonsoc.pendingMessage")
|
|
1159
|
+
setPendingMessage(messageID)
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
|
1163
|
+
const root = scroller
|
|
1164
|
+
if (!root) return false
|
|
1165
|
+
|
|
1166
|
+
const a = el.getBoundingClientRect()
|
|
1167
|
+
const b = root.getBoundingClientRect()
|
|
1168
|
+
const top = a.top - b.top + root.scrollTop
|
|
1169
|
+
root.scrollTo({ top, behavior })
|
|
1170
|
+
return true
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
|
1174
|
+
setActiveMessage(message)
|
|
1175
|
+
|
|
1176
|
+
const msgs = visibleUserMessages()
|
|
1177
|
+
const index = msgs.findIndex((m) => m.id === message.id)
|
|
1178
|
+
if (index !== -1 && index < store.turnStart) {
|
|
1179
|
+
setStore("turnStart", index)
|
|
1180
|
+
scheduleTurnBackfill()
|
|
1181
|
+
|
|
1182
|
+
requestAnimationFrame(() => {
|
|
1183
|
+
const el = document.getElementById(anchor(message.id))
|
|
1184
|
+
if (!el) {
|
|
1185
|
+
requestAnimationFrame(() => {
|
|
1186
|
+
const next = document.getElementById(anchor(message.id))
|
|
1187
|
+
if (!next) return
|
|
1188
|
+
scrollToElement(next, behavior)
|
|
1189
|
+
})
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
scrollToElement(el, behavior)
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
updateHash(message.id)
|
|
1196
|
+
return
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const el = document.getElementById(anchor(message.id))
|
|
1200
|
+
if (!el) {
|
|
1201
|
+
updateHash(message.id)
|
|
1202
|
+
requestAnimationFrame(() => {
|
|
1203
|
+
const next = document.getElementById(anchor(message.id))
|
|
1204
|
+
if (!next) return
|
|
1205
|
+
if (!scrollToElement(next, behavior)) return
|
|
1206
|
+
})
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
if (scrollToElement(el, behavior)) {
|
|
1210
|
+
updateHash(message.id)
|
|
1211
|
+
return
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
requestAnimationFrame(() => {
|
|
1215
|
+
const next = document.getElementById(anchor(message.id))
|
|
1216
|
+
if (!next) return
|
|
1217
|
+
if (!scrollToElement(next, behavior)) return
|
|
1218
|
+
})
|
|
1219
|
+
updateHash(message.id)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const applyHash = (behavior: ScrollBehavior) => {
|
|
1223
|
+
const hash = window.location.hash.slice(1)
|
|
1224
|
+
if (!hash) {
|
|
1225
|
+
autoScroll.forceScrollToBottom()
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const match = hash.match(/^message-(.+)$/)
|
|
1230
|
+
if (match) {
|
|
1231
|
+
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
|
1232
|
+
if (msg) {
|
|
1233
|
+
scrollToMessage(msg, behavior)
|
|
1234
|
+
return
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// If we have a message hash but the message isn't loaded/rendered yet,
|
|
1238
|
+
// don't fall back to "bottom". We'll retry once messages arrive.
|
|
1239
|
+
return
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const target = document.getElementById(hash)
|
|
1243
|
+
if (target) {
|
|
1244
|
+
scrollToElement(target, behavior)
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
autoScroll.forceScrollToBottom()
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const getActiveMessageId = (container: HTMLDivElement) => {
|
|
1252
|
+
const cutoff = container.scrollTop + 100
|
|
1253
|
+
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
|
|
1254
|
+
let id: string | undefined
|
|
1255
|
+
|
|
1256
|
+
for (const node of nodes) {
|
|
1257
|
+
const next = node.dataset.messageId
|
|
1258
|
+
if (!next) continue
|
|
1259
|
+
if (node.offsetTop > cutoff) break
|
|
1260
|
+
id = next
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
return id
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const scheduleScrollSpy = (container: HTMLDivElement) => {
|
|
1267
|
+
scrollSpyTarget = container
|
|
1268
|
+
if (scrollSpyFrame !== undefined) return
|
|
1269
|
+
|
|
1270
|
+
scrollSpyFrame = requestAnimationFrame(() => {
|
|
1271
|
+
scrollSpyFrame = undefined
|
|
1272
|
+
|
|
1273
|
+
const target = scrollSpyTarget
|
|
1274
|
+
scrollSpyTarget = undefined
|
|
1275
|
+
if (!target) return
|
|
1276
|
+
|
|
1277
|
+
const id = getActiveMessageId(target)
|
|
1278
|
+
if (!id) return
|
|
1279
|
+
if (id === store.messageId) return
|
|
1280
|
+
|
|
1281
|
+
setStore("messageId", id)
|
|
1282
|
+
})
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
createEffect(() => {
|
|
1286
|
+
const sessionID = params.id
|
|
1287
|
+
const ready = messagesReady()
|
|
1288
|
+
if (!sessionID || !ready) return
|
|
1289
|
+
|
|
1290
|
+
requestAnimationFrame(() => {
|
|
1291
|
+
applyHash("auto")
|
|
1292
|
+
})
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
// Retry message navigation once the target message is actually loaded.
|
|
1296
|
+
createEffect(() => {
|
|
1297
|
+
const sessionID = params.id
|
|
1298
|
+
const ready = messagesReady()
|
|
1299
|
+
if (!sessionID || !ready) return
|
|
1300
|
+
|
|
1301
|
+
// dependencies
|
|
1302
|
+
visibleUserMessages().length
|
|
1303
|
+
store.turnStart
|
|
1304
|
+
|
|
1305
|
+
const targetId =
|
|
1306
|
+
pendingMessage() ??
|
|
1307
|
+
(() => {
|
|
1308
|
+
const hash = window.location.hash.slice(1)
|
|
1309
|
+
const match = hash.match(/^message-(.+)$/)
|
|
1310
|
+
if (!match) return undefined
|
|
1311
|
+
return match[1]
|
|
1312
|
+
})()
|
|
1313
|
+
if (!targetId) return
|
|
1314
|
+
if (store.messageId === targetId) return
|
|
1315
|
+
|
|
1316
|
+
const msg = visibleUserMessages().find((m) => m.id === targetId)
|
|
1317
|
+
if (!msg) return
|
|
1318
|
+
if (pendingMessage() === targetId) setPendingMessage(undefined)
|
|
1319
|
+
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
createEffect(() => {
|
|
1323
|
+
const sessionID = params.id
|
|
1324
|
+
const ready = messagesReady()
|
|
1325
|
+
if (!sessionID || !ready) return
|
|
1326
|
+
|
|
1327
|
+
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
|
1328
|
+
window.addEventListener("hashchange", handler)
|
|
1329
|
+
onCleanup(() => window.removeEventListener("hashchange", handler))
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
createEffect(() => {
|
|
1333
|
+
document.addEventListener("keydown", handleKeyDown)
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
const previewPrompt = () =>
|
|
1337
|
+
prompt
|
|
1338
|
+
.current()
|
|
1339
|
+
.map((part) => {
|
|
1340
|
+
if (part.type === "file") return `[file:${part.path}]`
|
|
1341
|
+
if (part.type === "agent") return `@${part.name}`
|
|
1342
|
+
if (part.type === "image") return `[image:${part.filename}]`
|
|
1343
|
+
return part.content
|
|
1344
|
+
})
|
|
1345
|
+
.join("")
|
|
1346
|
+
.trim()
|
|
1347
|
+
|
|
1348
|
+
createEffect(() => {
|
|
1349
|
+
if (!prompt.ready()) return
|
|
1350
|
+
handoff.prompt = previewPrompt()
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1353
|
+
createEffect(() => {
|
|
1354
|
+
if (!terminal.ready()) return
|
|
1355
|
+
language.locale()
|
|
1356
|
+
|
|
1357
|
+
const label = (pty: LocalPTY) => {
|
|
1358
|
+
const title = pty.title
|
|
1359
|
+
const number = pty.titleNumber
|
|
1360
|
+
const match = title.match(/^Terminal (\d+)$/)
|
|
1361
|
+
const parsed = match ? Number(match[1]) : undefined
|
|
1362
|
+
const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
|
|
1363
|
+
|
|
1364
|
+
if (title && !isDefaultTitle) return title
|
|
1365
|
+
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
|
|
1366
|
+
if (title) return title
|
|
1367
|
+
return language.t("terminal.title")
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
handoff.terminals = terminal.all().map(label)
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
createEffect(() => {
|
|
1374
|
+
if (!file.ready()) return
|
|
1375
|
+
handoff.files = Object.fromEntries(
|
|
1376
|
+
tabs()
|
|
1377
|
+
.all()
|
|
1378
|
+
.flatMap((tab) => {
|
|
1379
|
+
const path = file.pathFromTab(tab)
|
|
1380
|
+
if (!path) return []
|
|
1381
|
+
return [[path, file.selectedLines(path) ?? null] as const]
|
|
1382
|
+
}),
|
|
1383
|
+
)
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
onCleanup(() => {
|
|
1387
|
+
cancelTurnBackfill()
|
|
1388
|
+
document.removeEventListener("keydown", handleKeyDown)
|
|
1389
|
+
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
return (
|
|
1393
|
+
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
|
1394
|
+
<SessionHeader />
|
|
1395
|
+
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
|
1396
|
+
{/* Mobile tab bar - only shown on mobile when user opened review */}
|
|
1397
|
+
<Show when={!isDesktop() && layout.review.panelOpened()}>
|
|
1398
|
+
<Tabs class="h-auto">
|
|
1399
|
+
<Tabs.List>
|
|
1400
|
+
<Tabs.Trigger
|
|
1401
|
+
value="session"
|
|
1402
|
+
class="w-1/2"
|
|
1403
|
+
classes={{ button: "w-full" }}
|
|
1404
|
+
onClick={() => setStore("mobileTab", "session")}
|
|
1405
|
+
>
|
|
1406
|
+
Session
|
|
1407
|
+
</Tabs.Trigger>
|
|
1408
|
+
<Tabs.Trigger
|
|
1409
|
+
value="review"
|
|
1410
|
+
class="w-1/2 !border-r-0"
|
|
1411
|
+
classes={{ button: "w-full" }}
|
|
1412
|
+
onClick={() => setStore("mobileTab", "review")}
|
|
1413
|
+
>
|
|
1414
|
+
<Switch>
|
|
1415
|
+
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
|
|
1416
|
+
<Match when={true}>Review</Match>
|
|
1417
|
+
</Switch>
|
|
1418
|
+
</Tabs.Trigger>
|
|
1419
|
+
</Tabs.List>
|
|
1420
|
+
</Tabs>
|
|
1421
|
+
</Show>
|
|
1422
|
+
|
|
1423
|
+
{/* Session panel */}
|
|
1424
|
+
<div
|
|
1425
|
+
classList={{
|
|
1426
|
+
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
|
1427
|
+
"flex-1 md:flex-none py-6 md:py-3": true,
|
|
1428
|
+
}}
|
|
1429
|
+
style={{
|
|
1430
|
+
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
|
|
1431
|
+
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
|
|
1432
|
+
}}
|
|
1433
|
+
>
|
|
1434
|
+
<div class="flex-1 min-h-0 overflow-hidden relative">
|
|
1435
|
+
{/* Session view - always mounted, opacity transition */}
|
|
1436
|
+
<div
|
|
1437
|
+
class="absolute inset-0 transition-opacity duration-200"
|
|
1438
|
+
classList={{
|
|
1439
|
+
"opacity-100 pointer-events-auto": !!params.id,
|
|
1440
|
+
"opacity-0 pointer-events-none": !params.id,
|
|
1441
|
+
}}
|
|
1442
|
+
>
|
|
1443
|
+
<Show when={activeMessage()}>
|
|
1444
|
+
<Show
|
|
1445
|
+
when={!mobileReview()}
|
|
1446
|
+
fallback={
|
|
1447
|
+
<div class="relative h-full overflow-hidden">
|
|
1448
|
+
<Switch>
|
|
1449
|
+
<Match when={hasReview()}>
|
|
1450
|
+
<Show
|
|
1451
|
+
when={diffsReady()}
|
|
1452
|
+
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
|
1453
|
+
>
|
|
1454
|
+
<SessionReviewTab
|
|
1455
|
+
diffs={diffs}
|
|
1456
|
+
view={view}
|
|
1457
|
+
diffStyle="unified"
|
|
1458
|
+
onLineComment={addCommentToContext}
|
|
1459
|
+
comments={comments.all()}
|
|
1460
|
+
focusedComment={comments.focus()}
|
|
1461
|
+
onFocusedCommentChange={comments.setFocus}
|
|
1462
|
+
onViewFile={(path) => {
|
|
1463
|
+
const value = file.tab(path)
|
|
1464
|
+
tabs().open(value)
|
|
1465
|
+
file.load(path)
|
|
1466
|
+
}}
|
|
1467
|
+
classes={{
|
|
1468
|
+
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
|
1469
|
+
header: "px-4",
|
|
1470
|
+
container: "px-4",
|
|
1471
|
+
}}
|
|
1472
|
+
/>
|
|
1473
|
+
</Show>
|
|
1474
|
+
</Match>
|
|
1475
|
+
<Match when={true}>
|
|
1476
|
+
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
|
1477
|
+
<Mark class="w-14 opacity-10" />
|
|
1478
|
+
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
|
1479
|
+
</div>
|
|
1480
|
+
</Match>
|
|
1481
|
+
</Switch>
|
|
1482
|
+
</div>
|
|
1483
|
+
}
|
|
1484
|
+
>
|
|
1485
|
+
<div class="relative w-full h-full min-w-0">
|
|
1486
|
+
<div
|
|
1487
|
+
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
|
|
1488
|
+
classList={{
|
|
1489
|
+
"opacity-100 translate-y-0 scale-100": autoScroll.userScrolled(),
|
|
1490
|
+
"opacity-0 translate-y-2 scale-95 pointer-events-none": !autoScroll.userScrolled(),
|
|
1491
|
+
}}
|
|
1492
|
+
>
|
|
1493
|
+
<button
|
|
1494
|
+
class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
|
|
1495
|
+
onClick={() => {
|
|
1496
|
+
setStore("messageId", undefined)
|
|
1497
|
+
autoScroll.forceScrollToBottom()
|
|
1498
|
+
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
|
|
1499
|
+
}}
|
|
1500
|
+
>
|
|
1501
|
+
<Icon name="arrow-down-to-line" />
|
|
1502
|
+
</button>
|
|
1503
|
+
</div>
|
|
1504
|
+
<div
|
|
1505
|
+
ref={setScrollRef}
|
|
1506
|
+
onWheel={(e) => markScrollGesture(e.target)}
|
|
1507
|
+
onTouchMove={(e) => markScrollGesture(e.target)}
|
|
1508
|
+
onPointerDown={(e) => {
|
|
1509
|
+
if (e.target !== e.currentTarget) return
|
|
1510
|
+
markScrollGesture(e.target)
|
|
1511
|
+
}}
|
|
1512
|
+
onScroll={(e) => {
|
|
1513
|
+
if (!hasScrollGesture()) return
|
|
1514
|
+
markScrollGesture(e.target)
|
|
1515
|
+
autoScroll.handleScroll()
|
|
1516
|
+
if (isDesktop()) scheduleScrollSpy(e.currentTarget)
|
|
1517
|
+
}}
|
|
1518
|
+
onClick={autoScroll.handleInteraction}
|
|
1519
|
+
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
|
1520
|
+
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
|
|
1521
|
+
>
|
|
1522
|
+
<Show when={info()?.title}>
|
|
1523
|
+
<div
|
|
1524
|
+
classList={{
|
|
1525
|
+
"sticky top-0 z-30 bg-background-stronger": true,
|
|
1526
|
+
"w-full": true,
|
|
1527
|
+
"px-4 md:px-6": true,
|
|
1528
|
+
"md:max-w-200 md:mx-auto": !showTabs(),
|
|
1529
|
+
}}
|
|
1530
|
+
>
|
|
1531
|
+
<div class="h-10 flex items-center">
|
|
1532
|
+
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
|
1533
|
+
</div>
|
|
1534
|
+
</div>
|
|
1535
|
+
</Show>
|
|
1536
|
+
|
|
1537
|
+
<div
|
|
1538
|
+
ref={autoScroll.contentRef}
|
|
1539
|
+
role="log"
|
|
1540
|
+
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
|
1541
|
+
classList={{
|
|
1542
|
+
"w-full": true,
|
|
1543
|
+
"md:max-w-200 md:mx-auto": !showTabs(),
|
|
1544
|
+
"mt-0.5": !showTabs(),
|
|
1545
|
+
"mt-0": showTabs(),
|
|
1546
|
+
}}
|
|
1547
|
+
>
|
|
1548
|
+
<Show when={store.turnStart > 0}>
|
|
1549
|
+
<div class="w-full flex justify-center">
|
|
1550
|
+
<Button
|
|
1551
|
+
variant="ghost"
|
|
1552
|
+
size="large"
|
|
1553
|
+
class="text-12-medium opacity-50"
|
|
1554
|
+
onClick={() => setStore("turnStart", 0)}
|
|
1555
|
+
>
|
|
1556
|
+
Render earlier messages
|
|
1557
|
+
</Button>
|
|
1558
|
+
</div>
|
|
1559
|
+
</Show>
|
|
1560
|
+
<Show when={historyMore()}>
|
|
1561
|
+
<div class="w-full flex justify-center">
|
|
1562
|
+
<Button
|
|
1563
|
+
variant="ghost"
|
|
1564
|
+
size="large"
|
|
1565
|
+
class="text-12-medium opacity-50"
|
|
1566
|
+
disabled={historyLoading()}
|
|
1567
|
+
onClick={() => {
|
|
1568
|
+
const id = params.id
|
|
1569
|
+
if (!id) return
|
|
1570
|
+
setStore("turnStart", 0)
|
|
1571
|
+
sync.session.history.loadMore(id)
|
|
1572
|
+
}}
|
|
1573
|
+
>
|
|
1574
|
+
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
|
|
1575
|
+
</Button>
|
|
1576
|
+
</div>
|
|
1577
|
+
</Show>
|
|
1578
|
+
<For each={renderedUserMessages()}>
|
|
1579
|
+
{(message) => {
|
|
1580
|
+
if (import.meta.env.DEV) {
|
|
1581
|
+
onMount(() => {
|
|
1582
|
+
const id = params.id
|
|
1583
|
+
if (!id) return
|
|
1584
|
+
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
|
1585
|
+
})
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return (
|
|
1589
|
+
<div
|
|
1590
|
+
id={anchor(message.id)}
|
|
1591
|
+
data-message-id={message.id}
|
|
1592
|
+
classList={{
|
|
1593
|
+
"min-w-0 w-full max-w-full": true,
|
|
1594
|
+
"md:max-w-200": !showTabs(),
|
|
1595
|
+
}}
|
|
1596
|
+
>
|
|
1597
|
+
<SessionTurn
|
|
1598
|
+
sessionID={params.id!}
|
|
1599
|
+
messageID={message.id}
|
|
1600
|
+
lastUserMessageID={lastUserMessage()?.id}
|
|
1601
|
+
stepsExpanded={store.expanded[message.id] ?? false}
|
|
1602
|
+
onStepsExpandedToggle={() =>
|
|
1603
|
+
setStore("expanded", message.id, (open: boolean | undefined) => !open)
|
|
1604
|
+
}
|
|
1605
|
+
classes={{
|
|
1606
|
+
root: "min-w-0 w-full relative",
|
|
1607
|
+
content: "flex flex-col justify-between !overflow-visible",
|
|
1608
|
+
container: "w-full px-4 md:px-6",
|
|
1609
|
+
}}
|
|
1610
|
+
/>
|
|
1611
|
+
</div>
|
|
1612
|
+
)
|
|
1613
|
+
}}
|
|
1614
|
+
</For>
|
|
1615
|
+
</div>
|
|
1616
|
+
</div>
|
|
1617
|
+
</div>
|
|
1618
|
+
</Show>
|
|
1619
|
+
</Show>
|
|
1620
|
+
</div>
|
|
1621
|
+
|
|
1622
|
+
{/* New session view - always mounted, opacity transition */}
|
|
1623
|
+
<div
|
|
1624
|
+
class="absolute inset-0 transition-opacity duration-200"
|
|
1625
|
+
classList={{
|
|
1626
|
+
"opacity-100 pointer-events-auto": !params.id,
|
|
1627
|
+
"opacity-0 pointer-events-none": !!params.id,
|
|
1628
|
+
}}
|
|
1629
|
+
>
|
|
1630
|
+
<NewSessionView
|
|
1631
|
+
worktree={newSessionWorktree()}
|
|
1632
|
+
onWorktreeChange={(value) => {
|
|
1633
|
+
if (value === "create") {
|
|
1634
|
+
setStore("newSessionWorktree", value)
|
|
1635
|
+
return
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
setStore("newSessionWorktree", "main")
|
|
1639
|
+
|
|
1640
|
+
const target = value === "main" ? sync.project?.worktree : value
|
|
1641
|
+
if (!target) return
|
|
1642
|
+
if (target === sync.data.path.directory) return
|
|
1643
|
+
layout.projects.open(target)
|
|
1644
|
+
navigate(`/${base64Encode(target)}/session`)
|
|
1645
|
+
}}
|
|
1646
|
+
/>
|
|
1647
|
+
</div>
|
|
1648
|
+
</div>
|
|
1649
|
+
|
|
1650
|
+
{/* Prompt input */}
|
|
1651
|
+
<div
|
|
1652
|
+
ref={(el) => (promptDock = el)}
|
|
1653
|
+
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
|
1654
|
+
>
|
|
1655
|
+
<div
|
|
1656
|
+
classList={{
|
|
1657
|
+
"w-full md:px-6 pointer-events-auto": true,
|
|
1658
|
+
"md:max-w-200": !showTabs(),
|
|
1659
|
+
}}
|
|
1660
|
+
>
|
|
1661
|
+
<Show
|
|
1662
|
+
when={prompt.ready()}
|
|
1663
|
+
fallback={
|
|
1664
|
+
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
|
1665
|
+
{handoff.prompt || "Loading prompt..."}
|
|
1666
|
+
</div>
|
|
1667
|
+
}
|
|
1668
|
+
>
|
|
1669
|
+
<PromptInput
|
|
1670
|
+
ref={(el) => {
|
|
1671
|
+
inputRef = el
|
|
1672
|
+
}}
|
|
1673
|
+
newSessionWorktree={newSessionWorktree()}
|
|
1674
|
+
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
|
1675
|
+
onSubmit={resumeScroll}
|
|
1676
|
+
/>
|
|
1677
|
+
</Show>
|
|
1678
|
+
</div>
|
|
1679
|
+
</div>
|
|
1680
|
+
|
|
1681
|
+
<Show when={isDesktop() && showTabs()}>
|
|
1682
|
+
<ResizeHandle
|
|
1683
|
+
direction="horizontal"
|
|
1684
|
+
size={layout.session.width()}
|
|
1685
|
+
min={450}
|
|
1686
|
+
max={window.innerWidth * 0.45}
|
|
1687
|
+
onResize={layout.session.resize}
|
|
1688
|
+
/>
|
|
1689
|
+
</Show>
|
|
1690
|
+
</div>
|
|
1691
|
+
|
|
1692
|
+
{/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
|
|
1693
|
+
<Show when={isDesktop() && showTabs()}>
|
|
1694
|
+
<aside
|
|
1695
|
+
id="review-panel"
|
|
1696
|
+
aria-label={language.t("session.panel.reviewAndFiles")}
|
|
1697
|
+
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base"
|
|
1698
|
+
>
|
|
1699
|
+
<DragDropProvider
|
|
1700
|
+
onDragStart={handleDragStart}
|
|
1701
|
+
onDragEnd={handleDragEnd}
|
|
1702
|
+
onDragOver={handleDragOver}
|
|
1703
|
+
collisionDetector={closestCenter}
|
|
1704
|
+
>
|
|
1705
|
+
<DragDropSensors />
|
|
1706
|
+
<ConstrainDragYAxis />
|
|
1707
|
+
<Tabs value={activeTab()} onChange={openTab}>
|
|
1708
|
+
<div class="sticky top-0 shrink-0 flex">
|
|
1709
|
+
<Tabs.List>
|
|
1710
|
+
<Show when={true}>
|
|
1711
|
+
<Tabs.Trigger value="review">
|
|
1712
|
+
<div class="flex items-center gap-3">
|
|
1713
|
+
<Show when={diffs()}>
|
|
1714
|
+
<DiffChanges changes={diffs()} variant="bars" />
|
|
1715
|
+
</Show>
|
|
1716
|
+
<div class="flex items-center gap-1.5">
|
|
1717
|
+
<div>Review</div>
|
|
1718
|
+
<Show when={info()?.summary?.files}>
|
|
1719
|
+
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
|
1720
|
+
{info()?.summary?.files ?? 0}
|
|
1721
|
+
</div>
|
|
1722
|
+
</Show>
|
|
1723
|
+
</div>
|
|
1724
|
+
</div>
|
|
1725
|
+
</Tabs.Trigger>
|
|
1726
|
+
</Show>
|
|
1727
|
+
<Show when={contextOpen()}>
|
|
1728
|
+
<Tabs.Trigger
|
|
1729
|
+
value="context"
|
|
1730
|
+
closeButton={
|
|
1731
|
+
<Tooltip value={language.t("common.closeTab")} placement="bottom">
|
|
1732
|
+
<IconButton
|
|
1733
|
+
icon="close"
|
|
1734
|
+
variant="ghost"
|
|
1735
|
+
onClick={() => tabs().close("context")}
|
|
1736
|
+
aria-label={language.t("common.closeTab")}
|
|
1737
|
+
/>
|
|
1738
|
+
</Tooltip>
|
|
1739
|
+
}
|
|
1740
|
+
hideCloseButton
|
|
1741
|
+
onMiddleClick={() => tabs().close("context")}
|
|
1742
|
+
>
|
|
1743
|
+
<div class="flex items-center gap-2">
|
|
1744
|
+
<SessionContextUsage variant="indicator" />
|
|
1745
|
+
<div>Context</div>
|
|
1746
|
+
</div>
|
|
1747
|
+
</Tabs.Trigger>
|
|
1748
|
+
</Show>
|
|
1749
|
+
<SortableProvider ids={openedTabs()}>
|
|
1750
|
+
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
|
1751
|
+
</SortableProvider>
|
|
1752
|
+
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
|
1753
|
+
<TooltipKeybind
|
|
1754
|
+
title="Open file"
|
|
1755
|
+
keybind={command.keybind("file.open")}
|
|
1756
|
+
class="flex items-center"
|
|
1757
|
+
>
|
|
1758
|
+
<IconButton
|
|
1759
|
+
icon="plus-small"
|
|
1760
|
+
variant="ghost"
|
|
1761
|
+
iconSize="large"
|
|
1762
|
+
onClick={() => dialog.show(() => <DialogSelectFile />)}
|
|
1763
|
+
aria-label={language.t("command.file.open")}
|
|
1764
|
+
/>
|
|
1765
|
+
</TooltipKeybind>
|
|
1766
|
+
</div>
|
|
1767
|
+
</Tabs.List>
|
|
1768
|
+
</div>
|
|
1769
|
+
<Show when={true}>
|
|
1770
|
+
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
1771
|
+
<Show when={activeTab() === "review"}>
|
|
1772
|
+
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
1773
|
+
<Switch>
|
|
1774
|
+
<Match when={hasReview()}>
|
|
1775
|
+
<Show
|
|
1776
|
+
when={diffsReady()}
|
|
1777
|
+
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
|
1778
|
+
>
|
|
1779
|
+
<SessionReviewTab
|
|
1780
|
+
diffs={diffs}
|
|
1781
|
+
view={view}
|
|
1782
|
+
diffStyle={layout.review.diffStyle()}
|
|
1783
|
+
onDiffStyleChange={layout.review.setDiffStyle}
|
|
1784
|
+
onLineComment={addCommentToContext}
|
|
1785
|
+
comments={comments.all()}
|
|
1786
|
+
focusedComment={comments.focus()}
|
|
1787
|
+
onFocusedCommentChange={comments.setFocus}
|
|
1788
|
+
onViewFile={(path) => {
|
|
1789
|
+
const value = file.tab(path)
|
|
1790
|
+
tabs().open(value)
|
|
1791
|
+
file.load(path)
|
|
1792
|
+
}}
|
|
1793
|
+
/>
|
|
1794
|
+
</Show>
|
|
1795
|
+
</Match>
|
|
1796
|
+
<Match when={true}>
|
|
1797
|
+
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
|
1798
|
+
<Mark class="w-14 opacity-10" />
|
|
1799
|
+
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
|
1800
|
+
</div>
|
|
1801
|
+
</Match>
|
|
1802
|
+
</Switch>
|
|
1803
|
+
</div>
|
|
1804
|
+
</Show>
|
|
1805
|
+
</Tabs.Content>
|
|
1806
|
+
</Show>
|
|
1807
|
+
<Show when={contextOpen()}>
|
|
1808
|
+
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
1809
|
+
<Show when={activeTab() === "context"}>
|
|
1810
|
+
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
1811
|
+
<SessionContextTab
|
|
1812
|
+
messages={messages}
|
|
1813
|
+
visibleUserMessages={visibleUserMessages}
|
|
1814
|
+
view={view}
|
|
1815
|
+
info={info}
|
|
1816
|
+
/>
|
|
1817
|
+
</div>
|
|
1818
|
+
</Show>
|
|
1819
|
+
</Tabs.Content>
|
|
1820
|
+
</Show>
|
|
1821
|
+
<For each={openedTabs()}>
|
|
1822
|
+
{(tab) => {
|
|
1823
|
+
let scroll: HTMLDivElement | undefined
|
|
1824
|
+
let scrollFrame: number | undefined
|
|
1825
|
+
let pending: { x: number; y: number } | undefined
|
|
1826
|
+
let codeScroll: HTMLElement[] = []
|
|
1827
|
+
|
|
1828
|
+
const path = createMemo(() => file.pathFromTab(tab))
|
|
1829
|
+
const state = createMemo(() => {
|
|
1830
|
+
const p = path()
|
|
1831
|
+
if (!p) return
|
|
1832
|
+
return file.get(p)
|
|
1833
|
+
})
|
|
1834
|
+
const contents = createMemo(() => state()?.content?.content ?? "")
|
|
1835
|
+
const cacheKey = createMemo(() => checksum(contents()))
|
|
1836
|
+
const isImage = createMemo(() => {
|
|
1837
|
+
const c = state()?.content
|
|
1838
|
+
return (
|
|
1839
|
+
c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
|
|
1840
|
+
)
|
|
1841
|
+
})
|
|
1842
|
+
const isSvg = createMemo(() => {
|
|
1843
|
+
const c = state()?.content
|
|
1844
|
+
return c?.mimeType === "image/svg+xml"
|
|
1845
|
+
})
|
|
1846
|
+
const svgContent = createMemo(() => {
|
|
1847
|
+
if (!isSvg()) return
|
|
1848
|
+
const c = state()?.content
|
|
1849
|
+
if (!c) return
|
|
1850
|
+
if (c.encoding === "base64") return base64Decode(c.content)
|
|
1851
|
+
return c.content
|
|
1852
|
+
})
|
|
1853
|
+
const svgPreviewUrl = createMemo(() => {
|
|
1854
|
+
if (!isSvg()) return
|
|
1855
|
+
const c = state()?.content
|
|
1856
|
+
if (!c) return
|
|
1857
|
+
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
|
|
1858
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
|
|
1859
|
+
})
|
|
1860
|
+
const imageDataUrl = createMemo(() => {
|
|
1861
|
+
if (!isImage()) return
|
|
1862
|
+
const c = state()?.content
|
|
1863
|
+
return `data:${c?.mimeType};base64,${c?.content}`
|
|
1864
|
+
})
|
|
1865
|
+
const selectedLines = createMemo(() => {
|
|
1866
|
+
const p = path()
|
|
1867
|
+
if (!p) return null
|
|
1868
|
+
if (file.ready()) return file.selectedLines(p) ?? null
|
|
1869
|
+
return handoff.files[p] ?? null
|
|
1870
|
+
})
|
|
1871
|
+
|
|
1872
|
+
let wrap: HTMLDivElement | undefined
|
|
1873
|
+
let textarea: HTMLTextAreaElement | undefined
|
|
1874
|
+
|
|
1875
|
+
const fileComments = createMemo(() => {
|
|
1876
|
+
const p = path()
|
|
1877
|
+
if (!p) return []
|
|
1878
|
+
return comments.list(p)
|
|
1879
|
+
})
|
|
1880
|
+
|
|
1881
|
+
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
|
1882
|
+
|
|
1883
|
+
const [openedComment, setOpenedComment] = createSignal<string | null>(null)
|
|
1884
|
+
const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
|
|
1885
|
+
const [draft, setDraft] = createSignal("")
|
|
1886
|
+
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
|
1887
|
+
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
|
1888
|
+
|
|
1889
|
+
const commentLabel = (range: SelectedLineRange) => {
|
|
1890
|
+
const start = Math.min(range.start, range.end)
|
|
1891
|
+
const end = Math.max(range.start, range.end)
|
|
1892
|
+
if (start === end) return `line ${start}`
|
|
1893
|
+
return `lines ${start}-${end}`
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const getRoot = () => {
|
|
1897
|
+
const el = wrap
|
|
1898
|
+
if (!el) return
|
|
1899
|
+
|
|
1900
|
+
const host = el.querySelector("diffs-container")
|
|
1901
|
+
if (!(host instanceof HTMLElement)) return
|
|
1902
|
+
|
|
1903
|
+
const root = host.shadowRoot
|
|
1904
|
+
if (!root) return
|
|
1905
|
+
|
|
1906
|
+
return root
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
|
1910
|
+
const line = Math.max(range.start, range.end)
|
|
1911
|
+
const node = root.querySelector(`[data-line="${line}"]`)
|
|
1912
|
+
if (!(node instanceof HTMLElement)) return
|
|
1913
|
+
return node
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
|
1917
|
+
const wrapperRect = wrapper.getBoundingClientRect()
|
|
1918
|
+
const rect = marker.getBoundingClientRect()
|
|
1919
|
+
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const updateComments = () => {
|
|
1923
|
+
const el = wrap
|
|
1924
|
+
const root = getRoot()
|
|
1925
|
+
if (!el || !root) {
|
|
1926
|
+
setPositions({})
|
|
1927
|
+
setDraftTop(undefined)
|
|
1928
|
+
return
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const next: Record<string, number> = {}
|
|
1932
|
+
for (const comment of fileComments()) {
|
|
1933
|
+
const marker = findMarker(root, comment.selection)
|
|
1934
|
+
if (!marker) continue
|
|
1935
|
+
next[comment.id] = markerTop(el, marker)
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
setPositions(next)
|
|
1939
|
+
|
|
1940
|
+
const range = commenting()
|
|
1941
|
+
if (!range) {
|
|
1942
|
+
setDraftTop(undefined)
|
|
1943
|
+
return
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const marker = findMarker(root, range)
|
|
1947
|
+
if (!marker) {
|
|
1948
|
+
setDraftTop(undefined)
|
|
1949
|
+
return
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
setDraftTop(markerTop(el, marker))
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const scheduleComments = () => {
|
|
1956
|
+
requestAnimationFrame(updateComments)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
createEffect(() => {
|
|
1960
|
+
fileComments()
|
|
1961
|
+
scheduleComments()
|
|
1962
|
+
})
|
|
1963
|
+
|
|
1964
|
+
createEffect(() => {
|
|
1965
|
+
commenting()
|
|
1966
|
+
scheduleComments()
|
|
1967
|
+
})
|
|
1968
|
+
|
|
1969
|
+
createEffect(() => {
|
|
1970
|
+
const range = commenting()
|
|
1971
|
+
if (!range) return
|
|
1972
|
+
setDraft("")
|
|
1973
|
+
requestAnimationFrame(() => textarea?.focus())
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
const renderCode = (source: string, wrapperClass: string) => {
|
|
1977
|
+
const filename = path() ?? ""
|
|
1978
|
+
const lang = filename ? getFiletypeFromFileName(filename) : "text"
|
|
1979
|
+
return (
|
|
1980
|
+
<div
|
|
1981
|
+
ref={(el) => {
|
|
1982
|
+
wrap = el
|
|
1983
|
+
scheduleComments()
|
|
1984
|
+
}}
|
|
1985
|
+
class={`relative overflow-hidden ${wrapperClass}`}
|
|
1986
|
+
>
|
|
1987
|
+
<Dynamic
|
|
1988
|
+
component={codeComponent}
|
|
1989
|
+
file={{
|
|
1990
|
+
name: filename,
|
|
1991
|
+
contents: source,
|
|
1992
|
+
cacheKey: cacheKey(),
|
|
1993
|
+
lang,
|
|
1994
|
+
}}
|
|
1995
|
+
enableLineSelection
|
|
1996
|
+
selectedLines={selectedLines()}
|
|
1997
|
+
commentedLines={commentedLines()}
|
|
1998
|
+
onRendered={() => {
|
|
1999
|
+
requestAnimationFrame(restoreScroll)
|
|
2000
|
+
requestAnimationFrame(scheduleComments)
|
|
2001
|
+
}}
|
|
2002
|
+
onLineSelected={(range: SelectedLineRange | null) => {
|
|
2003
|
+
const p = path()
|
|
2004
|
+
if (!p) return
|
|
2005
|
+
file.setSelectedLines(p, range)
|
|
2006
|
+
if (!range) setCommenting(null)
|
|
2007
|
+
}}
|
|
2008
|
+
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
|
2009
|
+
if (!range) {
|
|
2010
|
+
setCommenting(null)
|
|
2011
|
+
return
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
setOpenedComment(null)
|
|
2015
|
+
setCommenting(range)
|
|
2016
|
+
}}
|
|
2017
|
+
overflow="scroll"
|
|
2018
|
+
class="select-text"
|
|
2019
|
+
/>
|
|
2020
|
+
<For each={fileComments()}>
|
|
2021
|
+
{(comment) => (
|
|
2022
|
+
<div
|
|
2023
|
+
class="absolute right-6 z-30"
|
|
2024
|
+
style={{
|
|
2025
|
+
top: `${positions()[comment.id] ?? 0}px`,
|
|
2026
|
+
opacity: positions()[comment.id] === undefined ? 0 : 1,
|
|
2027
|
+
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
|
|
2028
|
+
}}
|
|
2029
|
+
>
|
|
2030
|
+
<button
|
|
2031
|
+
type="button"
|
|
2032
|
+
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
|
|
2033
|
+
onMouseEnter={() => {
|
|
2034
|
+
const p = path()
|
|
2035
|
+
if (!p) return
|
|
2036
|
+
file.setSelectedLines(p, comment.selection)
|
|
2037
|
+
}}
|
|
2038
|
+
onClick={() => {
|
|
2039
|
+
const p = path()
|
|
2040
|
+
if (!p) return
|
|
2041
|
+
setCommenting(null)
|
|
2042
|
+
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
|
2043
|
+
file.setSelectedLines(p, comment.selection)
|
|
2044
|
+
}}
|
|
2045
|
+
>
|
|
2046
|
+
<Icon name="speech-bubble" size="small" />
|
|
2047
|
+
</button>
|
|
2048
|
+
<Show when={openedComment() === comment.id}>
|
|
2049
|
+
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
|
|
2050
|
+
<div class="flex flex-col gap-1.5">
|
|
2051
|
+
<div class="text-12-medium text-text-strong whitespace-nowrap">
|
|
2052
|
+
{getFilename(comment.file)}:{commentLabel(comment.selection)}
|
|
2053
|
+
</div>
|
|
2054
|
+
<div class="text-12-regular text-text-base whitespace-pre-wrap">
|
|
2055
|
+
{comment.comment}
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
2058
|
+
</div>
|
|
2059
|
+
</Show>
|
|
2060
|
+
</div>
|
|
2061
|
+
)}
|
|
2062
|
+
</For>
|
|
2063
|
+
<Show when={commenting()}>
|
|
2064
|
+
{(range) => (
|
|
2065
|
+
<Show when={draftTop() !== undefined}>
|
|
2066
|
+
<div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
|
|
2067
|
+
<button
|
|
2068
|
+
type="button"
|
|
2069
|
+
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
|
|
2070
|
+
onClick={() => textarea?.focus()}
|
|
2071
|
+
>
|
|
2072
|
+
<Icon name="speech-bubble" size="small" />
|
|
2073
|
+
</button>
|
|
2074
|
+
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
|
|
2075
|
+
<div class="flex flex-col gap-2">
|
|
2076
|
+
<div class="text-12-medium text-text-strong">
|
|
2077
|
+
Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
|
|
2078
|
+
</div>
|
|
2079
|
+
<textarea
|
|
2080
|
+
ref={textarea}
|
|
2081
|
+
class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
|
|
2082
|
+
rows={3}
|
|
2083
|
+
placeholder="Add a comment"
|
|
2084
|
+
value={draft()}
|
|
2085
|
+
onInput={(e) => setDraft(e.currentTarget.value)}
|
|
2086
|
+
onKeyDown={(e) => {
|
|
2087
|
+
if (e.key !== "Enter") return
|
|
2088
|
+
if (e.shiftKey) return
|
|
2089
|
+
e.preventDefault()
|
|
2090
|
+
const value = draft().trim()
|
|
2091
|
+
if (!value) return
|
|
2092
|
+
const p = path()
|
|
2093
|
+
if (!p) return
|
|
2094
|
+
addCommentToContext({ file: p, selection: range(), comment: value })
|
|
2095
|
+
setCommenting(null)
|
|
2096
|
+
}}
|
|
2097
|
+
/>
|
|
2098
|
+
<div class="flex justify-end gap-2">
|
|
2099
|
+
<Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
|
|
2100
|
+
Cancel
|
|
2101
|
+
</Button>
|
|
2102
|
+
<Button
|
|
2103
|
+
size="small"
|
|
2104
|
+
variant="secondary"
|
|
2105
|
+
disabled={draft().trim().length === 0}
|
|
2106
|
+
onClick={() => {
|
|
2107
|
+
const value = draft().trim()
|
|
2108
|
+
if (!value) return
|
|
2109
|
+
const p = path()
|
|
2110
|
+
if (!p) return
|
|
2111
|
+
addCommentToContext({ file: p, selection: range(), comment: value })
|
|
2112
|
+
setCommenting(null)
|
|
2113
|
+
}}
|
|
2114
|
+
>
|
|
2115
|
+
Comment
|
|
2116
|
+
</Button>
|
|
2117
|
+
</div>
|
|
2118
|
+
</div>
|
|
2119
|
+
</div>
|
|
2120
|
+
</div>
|
|
2121
|
+
</Show>
|
|
2122
|
+
)}
|
|
2123
|
+
</Show>
|
|
2124
|
+
</div>
|
|
2125
|
+
)
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const getCodeScroll = () => {
|
|
2129
|
+
const el = scroll
|
|
2130
|
+
if (!el) return []
|
|
2131
|
+
|
|
2132
|
+
const host = el.querySelector("diffs-container")
|
|
2133
|
+
if (!(host instanceof HTMLElement)) return []
|
|
2134
|
+
|
|
2135
|
+
const root = host.shadowRoot
|
|
2136
|
+
if (!root) return []
|
|
2137
|
+
|
|
2138
|
+
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
|
2139
|
+
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
|
2140
|
+
)
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const queueScrollUpdate = (next: { x: number; y: number }) => {
|
|
2144
|
+
pending = next
|
|
2145
|
+
if (scrollFrame !== undefined) return
|
|
2146
|
+
|
|
2147
|
+
scrollFrame = requestAnimationFrame(() => {
|
|
2148
|
+
scrollFrame = undefined
|
|
2149
|
+
|
|
2150
|
+
const next = pending
|
|
2151
|
+
pending = undefined
|
|
2152
|
+
if (!next) return
|
|
2153
|
+
|
|
2154
|
+
view().setScroll(tab, next)
|
|
2155
|
+
})
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const handleCodeScroll = (event: Event) => {
|
|
2159
|
+
const el = scroll
|
|
2160
|
+
if (!el) return
|
|
2161
|
+
|
|
2162
|
+
const target = event.currentTarget
|
|
2163
|
+
if (!(target instanceof HTMLElement)) return
|
|
2164
|
+
|
|
2165
|
+
queueScrollUpdate({
|
|
2166
|
+
x: target.scrollLeft,
|
|
2167
|
+
y: el.scrollTop,
|
|
2168
|
+
})
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const syncCodeScroll = () => {
|
|
2172
|
+
const next = getCodeScroll()
|
|
2173
|
+
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
|
2174
|
+
|
|
2175
|
+
for (const item of codeScroll) {
|
|
2176
|
+
item.removeEventListener("scroll", handleCodeScroll)
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
codeScroll = next
|
|
2180
|
+
|
|
2181
|
+
for (const item of codeScroll) {
|
|
2182
|
+
item.addEventListener("scroll", handleCodeScroll)
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const restoreScroll = () => {
|
|
2187
|
+
const el = scroll
|
|
2188
|
+
if (!el) return
|
|
2189
|
+
|
|
2190
|
+
const s = view()?.scroll(tab)
|
|
2191
|
+
if (!s) return
|
|
2192
|
+
|
|
2193
|
+
syncCodeScroll()
|
|
2194
|
+
|
|
2195
|
+
if (codeScroll.length > 0) {
|
|
2196
|
+
for (const item of codeScroll) {
|
|
2197
|
+
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
|
2202
|
+
|
|
2203
|
+
if (codeScroll.length > 0) return
|
|
2204
|
+
|
|
2205
|
+
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
2209
|
+
if (codeScroll.length === 0) syncCodeScroll()
|
|
2210
|
+
|
|
2211
|
+
queueScrollUpdate({
|
|
2212
|
+
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
|
2213
|
+
y: event.currentTarget.scrollTop,
|
|
2214
|
+
})
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
createEffect(
|
|
2218
|
+
on(
|
|
2219
|
+
() => state()?.loaded,
|
|
2220
|
+
(loaded) => {
|
|
2221
|
+
if (!loaded) return
|
|
2222
|
+
requestAnimationFrame(restoreScroll)
|
|
2223
|
+
},
|
|
2224
|
+
{ defer: true },
|
|
2225
|
+
),
|
|
2226
|
+
)
|
|
2227
|
+
|
|
2228
|
+
createEffect(
|
|
2229
|
+
on(
|
|
2230
|
+
() => file.ready(),
|
|
2231
|
+
(ready) => {
|
|
2232
|
+
if (!ready) return
|
|
2233
|
+
requestAnimationFrame(restoreScroll)
|
|
2234
|
+
},
|
|
2235
|
+
{ defer: true },
|
|
2236
|
+
),
|
|
2237
|
+
)
|
|
2238
|
+
|
|
2239
|
+
createEffect(
|
|
2240
|
+
on(
|
|
2241
|
+
() => tabs().active() === tab,
|
|
2242
|
+
(active) => {
|
|
2243
|
+
if (!active) return
|
|
2244
|
+
if (!state()?.loaded) return
|
|
2245
|
+
requestAnimationFrame(restoreScroll)
|
|
2246
|
+
},
|
|
2247
|
+
),
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
onCleanup(() => {
|
|
2251
|
+
for (const item of codeScroll) {
|
|
2252
|
+
item.removeEventListener("scroll", handleCodeScroll)
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
if (scrollFrame === undefined) return
|
|
2256
|
+
cancelAnimationFrame(scrollFrame)
|
|
2257
|
+
})
|
|
2258
|
+
|
|
2259
|
+
return (
|
|
2260
|
+
<Tabs.Content
|
|
2261
|
+
value={tab}
|
|
2262
|
+
class="mt-3 relative"
|
|
2263
|
+
ref={(el: HTMLDivElement) => {
|
|
2264
|
+
scroll = el
|
|
2265
|
+
restoreScroll()
|
|
2266
|
+
}}
|
|
2267
|
+
onScroll={handleScroll}
|
|
2268
|
+
>
|
|
2269
|
+
<Switch>
|
|
2270
|
+
<Match when={state()?.loaded && isImage()}>
|
|
2271
|
+
<div class="px-6 py-4 pb-40">
|
|
2272
|
+
<img
|
|
2273
|
+
src={imageDataUrl()}
|
|
2274
|
+
alt={path()}
|
|
2275
|
+
class="max-w-full"
|
|
2276
|
+
onLoad={() => requestAnimationFrame(restoreScroll)}
|
|
2277
|
+
/>
|
|
2278
|
+
</div>
|
|
2279
|
+
</Match>
|
|
2280
|
+
<Match when={state()?.loaded && isSvg()}>
|
|
2281
|
+
<div class="flex flex-col gap-4 px-6 py-4">
|
|
2282
|
+
{renderCode(svgContent() ?? "", "")}
|
|
2283
|
+
<Show when={svgPreviewUrl()}>
|
|
2284
|
+
<div class="flex justify-center pb-40">
|
|
2285
|
+
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
|
2286
|
+
</div>
|
|
2287
|
+
</Show>
|
|
2288
|
+
</div>
|
|
2289
|
+
</Match>
|
|
2290
|
+
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
|
2291
|
+
<Match when={state()?.loading}>
|
|
2292
|
+
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
|
2293
|
+
</Match>
|
|
2294
|
+
<Match when={state()?.error}>
|
|
2295
|
+
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
|
2296
|
+
</Match>
|
|
2297
|
+
</Switch>
|
|
2298
|
+
</Tabs.Content>
|
|
2299
|
+
)
|
|
2300
|
+
}}
|
|
2301
|
+
</For>
|
|
2302
|
+
</Tabs>
|
|
2303
|
+
<DragOverlay>
|
|
2304
|
+
<Show when={store.activeDraggable}>
|
|
2305
|
+
{(tab) => {
|
|
2306
|
+
const path = createMemo(() => file.pathFromTab(tab()))
|
|
2307
|
+
return (
|
|
2308
|
+
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
|
2309
|
+
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
|
2310
|
+
</div>
|
|
2311
|
+
)
|
|
2312
|
+
}}
|
|
2313
|
+
</Show>
|
|
2314
|
+
</DragOverlay>
|
|
2315
|
+
</DragDropProvider>
|
|
2316
|
+
</aside>
|
|
2317
|
+
</Show>
|
|
2318
|
+
</div>
|
|
2319
|
+
|
|
2320
|
+
<Show when={isDesktop() && view().terminal.opened()}>
|
|
2321
|
+
<div
|
|
2322
|
+
id="terminal-panel"
|
|
2323
|
+
role="region"
|
|
2324
|
+
aria-label={language.t("terminal.title")}
|
|
2325
|
+
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
|
2326
|
+
style={{ height: `${layout.terminal.height()}px` }}
|
|
2327
|
+
>
|
|
2328
|
+
<ResizeHandle
|
|
2329
|
+
direction="vertical"
|
|
2330
|
+
size={layout.terminal.height()}
|
|
2331
|
+
min={100}
|
|
2332
|
+
max={window.innerHeight * 0.6}
|
|
2333
|
+
collapseThreshold={50}
|
|
2334
|
+
onResize={layout.terminal.resize}
|
|
2335
|
+
onCollapse={view().terminal.close}
|
|
2336
|
+
/>
|
|
2337
|
+
<Show
|
|
2338
|
+
when={terminal.ready()}
|
|
2339
|
+
fallback={
|
|
2340
|
+
<div class="flex flex-col h-full pointer-events-none">
|
|
2341
|
+
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
|
2342
|
+
<For each={handoff.terminals}>
|
|
2343
|
+
{(title) => (
|
|
2344
|
+
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
|
2345
|
+
{title}
|
|
2346
|
+
</div>
|
|
2347
|
+
)}
|
|
2348
|
+
</For>
|
|
2349
|
+
<div class="flex-1" />
|
|
2350
|
+
<div class="text-text-weak pr-2">Loading...</div>
|
|
2351
|
+
</div>
|
|
2352
|
+
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
|
|
2353
|
+
</div>
|
|
2354
|
+
}
|
|
2355
|
+
>
|
|
2356
|
+
<DragDropProvider
|
|
2357
|
+
onDragStart={handleTerminalDragStart}
|
|
2358
|
+
onDragEnd={handleTerminalDragEnd}
|
|
2359
|
+
onDragOver={handleTerminalDragOver}
|
|
2360
|
+
collisionDetector={closestCenter}
|
|
2361
|
+
>
|
|
2362
|
+
<DragDropSensors />
|
|
2363
|
+
<ConstrainDragYAxis />
|
|
2364
|
+
<div class="flex flex-col h-full">
|
|
2365
|
+
<Tabs
|
|
2366
|
+
variant="alt"
|
|
2367
|
+
value={terminal.active()}
|
|
2368
|
+
onChange={(id) => {
|
|
2369
|
+
// Only switch tabs if not in the middle of starting edit mode
|
|
2370
|
+
terminal.open(id)
|
|
2371
|
+
}}
|
|
2372
|
+
class="!h-auto !flex-none"
|
|
2373
|
+
>
|
|
2374
|
+
<Tabs.List class="h-10">
|
|
2375
|
+
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
|
2376
|
+
<For each={terminal.all()}>
|
|
2377
|
+
{(pty) => (
|
|
2378
|
+
<SortableTerminalTab
|
|
2379
|
+
terminal={pty}
|
|
2380
|
+
onClose={() => {
|
|
2381
|
+
view().terminal.close()
|
|
2382
|
+
setAutoCreated(false)
|
|
2383
|
+
}}
|
|
2384
|
+
/>
|
|
2385
|
+
)}
|
|
2386
|
+
</For>
|
|
2387
|
+
</SortableProvider>
|
|
2388
|
+
<div class="h-full flex items-center justify-center">
|
|
2389
|
+
<TooltipKeybind
|
|
2390
|
+
title={language.t("command.terminal.new")}
|
|
2391
|
+
keybind={command.keybind("terminal.new")}
|
|
2392
|
+
class="flex items-center"
|
|
2393
|
+
>
|
|
2394
|
+
<IconButton
|
|
2395
|
+
icon="plus-small"
|
|
2396
|
+
variant="ghost"
|
|
2397
|
+
iconSize="large"
|
|
2398
|
+
onClick={terminal.new}
|
|
2399
|
+
aria-label={language.t("command.terminal.new")}
|
|
2400
|
+
/>
|
|
2401
|
+
</TooltipKeybind>
|
|
2402
|
+
</div>
|
|
2403
|
+
</Tabs.List>
|
|
2404
|
+
</Tabs>
|
|
2405
|
+
<div class="flex-1 min-h-0 relative">
|
|
2406
|
+
<For each={terminal.all()}>
|
|
2407
|
+
{(pty) => {
|
|
2408
|
+
const [dismissed, setDismissed] = createSignal(false)
|
|
2409
|
+
return (
|
|
2410
|
+
<div
|
|
2411
|
+
id={`terminal-wrapper-${pty.id}`}
|
|
2412
|
+
class="absolute inset-0"
|
|
2413
|
+
style={{
|
|
2414
|
+
display: terminal.active() === pty.id ? "block" : "none",
|
|
2415
|
+
}}
|
|
2416
|
+
>
|
|
2417
|
+
<Terminal
|
|
2418
|
+
pty={pty}
|
|
2419
|
+
onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
|
|
2420
|
+
onConnect={() => {
|
|
2421
|
+
terminal.update({ id: pty.id, error: false })
|
|
2422
|
+
setDismissed(false)
|
|
2423
|
+
}}
|
|
2424
|
+
onConnectError={() => {
|
|
2425
|
+
setDismissed(false)
|
|
2426
|
+
terminal.update({ id: pty.id, error: true })
|
|
2427
|
+
}}
|
|
2428
|
+
/>
|
|
2429
|
+
<Show when={pty.error && !dismissed()}>
|
|
2430
|
+
<div
|
|
2431
|
+
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
|
|
2432
|
+
style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
|
|
2433
|
+
>
|
|
2434
|
+
<Icon
|
|
2435
|
+
name="circle-ban-sign"
|
|
2436
|
+
class="w-8 h-8"
|
|
2437
|
+
style={{ color: "rgba(239, 68, 68, 0.8)" }}
|
|
2438
|
+
/>
|
|
2439
|
+
<div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
|
|
2440
|
+
<div class="text-14-semibold mb-1">{language.t("terminal.connectionLost.title")}</div>
|
|
2441
|
+
<div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
|
|
2442
|
+
{language.t("terminal.connectionLost.description")}
|
|
2443
|
+
</div>
|
|
2444
|
+
</div>
|
|
2445
|
+
<button
|
|
2446
|
+
class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
|
|
2447
|
+
style={{
|
|
2448
|
+
"background-color": "rgba(255, 255, 255, 0.1)",
|
|
2449
|
+
color: "rgba(255, 255, 255, 0.7)",
|
|
2450
|
+
border: "1px solid rgba(255, 255, 255, 0.2)",
|
|
2451
|
+
}}
|
|
2452
|
+
onMouseEnter={(e) =>
|
|
2453
|
+
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
|
|
2454
|
+
}
|
|
2455
|
+
onMouseLeave={(e) =>
|
|
2456
|
+
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
|
|
2457
|
+
}
|
|
2458
|
+
onClick={() => setDismissed(true)}
|
|
2459
|
+
>
|
|
2460
|
+
{language.t("common.dismiss")}
|
|
2461
|
+
</button>
|
|
2462
|
+
</div>
|
|
2463
|
+
</Show>
|
|
2464
|
+
</div>
|
|
2465
|
+
)
|
|
2466
|
+
}}
|
|
2467
|
+
</For>
|
|
2468
|
+
</div>
|
|
2469
|
+
</div>
|
|
2470
|
+
<DragOverlay>
|
|
2471
|
+
<Show when={store.activeTerminalDraggable}>
|
|
2472
|
+
{(draggedId) => {
|
|
2473
|
+
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
|
2474
|
+
return (
|
|
2475
|
+
<Show when={pty()}>
|
|
2476
|
+
{(t) => (
|
|
2477
|
+
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
|
2478
|
+
{(() => {
|
|
2479
|
+
const title = t().title
|
|
2480
|
+
const number = t().titleNumber
|
|
2481
|
+
const match = title.match(/^Terminal (\d+)$/)
|
|
2482
|
+
const parsed = match ? Number(match[1]) : undefined
|
|
2483
|
+
const isDefaultTitle =
|
|
2484
|
+
Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
|
|
2485
|
+
|
|
2486
|
+
if (title && !isDefaultTitle) return title
|
|
2487
|
+
if (Number.isFinite(number) && number > 0)
|
|
2488
|
+
return language.t("terminal.title.numbered", { number })
|
|
2489
|
+
if (title) return title
|
|
2490
|
+
return language.t("terminal.title")
|
|
2491
|
+
})()}
|
|
2492
|
+
</div>
|
|
2493
|
+
)}
|
|
2494
|
+
</Show>
|
|
2495
|
+
)
|
|
2496
|
+
}}
|
|
2497
|
+
</Show>
|
|
2498
|
+
</DragOverlay>
|
|
2499
|
+
</DragDropProvider>
|
|
2500
|
+
</Show>
|
|
2501
|
+
</div>
|
|
2502
|
+
</Show>
|
|
2503
|
+
</div>
|
|
2504
|
+
)
|
|
2505
|
+
}
|