mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-22 16:44:36 +00:00
chore: refactoring and tests, splitting up files (#12495)
This commit is contained in:
40
packages/app/src/pages/session/file-tab-scroll.test.ts
Normal file
40
packages/app/src/pages/session/file-tab-scroll.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { nextTabListScrollLeft } from "./file-tab-scroll"
|
||||
|
||||
describe("nextTabListScrollLeft", () => {
|
||||
test("does not scroll when width shrinks", () => {
|
||||
const left = nextTabListScrollLeft({
|
||||
prevScrollWidth: 500,
|
||||
scrollWidth: 420,
|
||||
clientWidth: 300,
|
||||
prevContextOpen: false,
|
||||
contextOpen: false,
|
||||
})
|
||||
|
||||
expect(left).toBeUndefined()
|
||||
})
|
||||
|
||||
test("scrolls to start when context tab opens", () => {
|
||||
const left = nextTabListScrollLeft({
|
||||
prevScrollWidth: 400,
|
||||
scrollWidth: 500,
|
||||
clientWidth: 320,
|
||||
prevContextOpen: false,
|
||||
contextOpen: true,
|
||||
})
|
||||
|
||||
expect(left).toBe(0)
|
||||
})
|
||||
|
||||
test("scrolls to right edge for new file tabs", () => {
|
||||
const left = nextTabListScrollLeft({
|
||||
prevScrollWidth: 500,
|
||||
scrollWidth: 780,
|
||||
clientWidth: 300,
|
||||
prevContextOpen: true,
|
||||
contextOpen: true,
|
||||
})
|
||||
|
||||
expect(left).toBe(480)
|
||||
})
|
||||
})
|
||||
67
packages/app/src/pages/session/file-tab-scroll.ts
Normal file
67
packages/app/src/pages/session/file-tab-scroll.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
type Input = {
|
||||
prevScrollWidth: number
|
||||
scrollWidth: number
|
||||
clientWidth: number
|
||||
prevContextOpen: boolean
|
||||
contextOpen: boolean
|
||||
}
|
||||
|
||||
export const nextTabListScrollLeft = (input: Input) => {
|
||||
if (input.scrollWidth <= input.prevScrollWidth) return
|
||||
if (!input.prevContextOpen && input.contextOpen) return 0
|
||||
if (input.scrollWidth <= input.clientWidth) return
|
||||
return input.scrollWidth - input.clientWidth
|
||||
}
|
||||
|
||||
export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => {
|
||||
let frame: number | undefined
|
||||
let prevScrollWidth = input.el.scrollWidth
|
||||
let prevContextOpen = input.contextOpen()
|
||||
|
||||
const update = () => {
|
||||
const scrollWidth = input.el.scrollWidth
|
||||
const clientWidth = input.el.clientWidth
|
||||
const contextOpen = input.contextOpen()
|
||||
const left = nextTabListScrollLeft({
|
||||
prevScrollWidth,
|
||||
scrollWidth,
|
||||
clientWidth,
|
||||
prevContextOpen,
|
||||
contextOpen,
|
||||
})
|
||||
|
||||
if (left !== undefined) {
|
||||
input.el.scrollTo({
|
||||
left,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
|
||||
prevScrollWidth = scrollWidth
|
||||
prevContextOpen = contextOpen
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
|
||||
input.el.scrollLeft += e.deltaY > 0 ? 50 : -50
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
input.el.addEventListener("wheel", onWheel, { passive: false })
|
||||
const observer = new MutationObserver(schedule)
|
||||
observer.observe(input.el, { childList: true })
|
||||
|
||||
return () => {
|
||||
input.el.removeEventListener("wheel", onWheel)
|
||||
observer.disconnect()
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
516
packages/app/src/pages/session/file-tabs.tsx
Normal file
516
packages/app/src/pages/session/file-tabs.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function FileTabContent(props: {
|
||||
tab: string
|
||||
activeTab: () => string
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
||||
file: ReturnType<typeof useFile>
|
||||
comments: ReturnType<typeof useComments>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
codeComponent: NonNullable<ValidComponent>
|
||||
addCommentToContext: (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}) => void
|
||||
}) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
|
||||
const path = createMemo(() => props.file.pathFromTab(props.tab))
|
||||
const state = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
return props.file.get(p)
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => checksum(contents()))
|
||||
const isImage = createMemo(() => {
|
||||
const c = state()?.content
|
||||
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
|
||||
})
|
||||
const isSvg = createMemo(() => {
|
||||
const c = state()?.content
|
||||
return c?.mimeType === "image/svg+xml"
|
||||
})
|
||||
const isBinary = createMemo(() => state()?.content?.type === "binary")
|
||||
const svgContent = createMemo(() => {
|
||||
if (!isSvg()) return
|
||||
const c = state()?.content
|
||||
if (!c) return
|
||||
if (c.encoding !== "base64") return c.content
|
||||
return decode64(c.content)
|
||||
})
|
||||
|
||||
const svgDecodeFailed = createMemo(() => {
|
||||
if (!isSvg()) return false
|
||||
const c = state()?.content
|
||||
if (!c) return false
|
||||
if (c.encoding !== "base64") return false
|
||||
return svgContent() === undefined
|
||||
})
|
||||
|
||||
const svgToast = { shown: false }
|
||||
createEffect(() => {
|
||||
if (!svgDecodeFailed()) return
|
||||
if (svgToast.shown) return
|
||||
svgToast.shown = true
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: props.language.t("toast.file.loadFailed.title"),
|
||||
description: "Invalid base64 content.",
|
||||
})
|
||||
})
|
||||
const svgPreviewUrl = createMemo(() => {
|
||||
if (!isSvg()) return
|
||||
const c = state()?.content
|
||||
if (!c) return
|
||||
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
|
||||
})
|
||||
const imageDataUrl = createMemo(() => {
|
||||
if (!isImage()) return
|
||||
const c = state()?.content
|
||||
return `data:${c?.mimeType};base64,${c?.content}`
|
||||
})
|
||||
const selectedLines = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (props.file.ready()) return props.file.selectedLines(p) ?? null
|
||||
return props.handoffFiles()?.[p] ?? null
|
||||
})
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return []
|
||||
return props.comments.list(p)
|
||||
})
|
||||
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
|
||||
const [note, setNote] = createStore({
|
||||
openedComment: null as string | null,
|
||||
commenting: null as SelectedLineRange | null,
|
||||
draft: "",
|
||||
positions: {} as Record<string, number>,
|
||||
draftTop: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const openedComment = () => note.openedComment
|
||||
const setOpenedComment = (
|
||||
value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment),
|
||||
) => setNote("openedComment", value)
|
||||
|
||||
const commenting = () => note.commenting
|
||||
const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) =>
|
||||
setNote("commenting", value)
|
||||
|
||||
const draft = () => note.draft
|
||||
const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) =>
|
||||
setNote("draft", value)
|
||||
|
||||
const positions = () => note.positions
|
||||
const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) =>
|
||||
setNote("positions", value)
|
||||
|
||||
const draftTop = () => note.draftTop
|
||||
const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) =>
|
||||
setNote("draftTop", value)
|
||||
|
||||
const commentLabel = (range: SelectedLineRange) => {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return `line ${start}`
|
||||
return `lines ${start}-${end}`
|
||||
}
|
||||
|
||||
const getRoot = () => {
|
||||
const el = wrap
|
||||
if (!el) return
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const node = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
return node
|
||||
}
|
||||
|
||||
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setPositions({})
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const comment of fileComments()) {
|
||||
const marker = findMarker(root, comment.selection)
|
||||
if (!marker) continue
|
||||
next[comment.id] = markerTop(el, marker)
|
||||
}
|
||||
|
||||
setPositions(next)
|
||||
|
||||
const range = commenting()
|
||||
if (!range) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
}
|
||||
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
fileComments()
|
||||
scheduleComments()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const range = commenting()
|
||||
scheduleComments()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const focus = props.comments.focus()
|
||||
const p = path()
|
||||
if (!focus || !p) return
|
||||
if (focus.file !== p) return
|
||||
if (props.activeTab() !== props.tab) return
|
||||
|
||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||
if (!target) return
|
||||
|
||||
setOpenedComment(target.id)
|
||||
setCommenting(null)
|
||||
props.file.setSelectedLines(p, target.selection)
|
||||
requestAnimationFrame(() => props.comments.clearFocus())
|
||||
})
|
||||
|
||||
const getCodeScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const queueScrollUpdate = (next: { x: number; y: number }) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
props.view().setScroll(props.tab, out)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
queueScrollUpdate({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const syncCodeScroll = () => {
|
||||
const next = getCodeScroll()
|
||||
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
codeScroll = next
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.addEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
}
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const s = props.view()?.scroll(props.tab)
|
||||
if (!s) return
|
||||
|
||||
syncCodeScroll()
|
||||
|
||||
if (codeScroll.length > 0) {
|
||||
for (const item of codeScroll) {
|
||||
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
if (codeScroll.length > 0) return
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (codeScroll.length === 0) syncCodeScroll()
|
||||
|
||||
queueScrollUpdate({
|
||||
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => state()?.loaded,
|
||||
(loaded) => {
|
||||
if (!loaded) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.file.ready(),
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.tabs().active() === props.tab,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
if (!state()?.loaded) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame === undefined) return
|
||||
cancelAnimationFrame(scrollFrame)
|
||||
})
|
||||
|
||||
const renderCode = (source: string, wrapperClass: string) => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
wrap = el
|
||||
scheduleComments()
|
||||
}}
|
||||
class={`relative overflow-hidden ${wrapperClass}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={props.codeComponent}
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
requestAnimationFrame(scheduleComments)
|
||||
}}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
props.file.setSelectedLines(p, range)
|
||||
if (!range) setCommenting(null)
|
||||
}}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setOpenedComment(null)
|
||||
setCommenting(range)
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
open={openedComment() === comment.id}
|
||||
comment={comment.comment}
|
||||
selection={commentLabel(comment.selection)}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
props.file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
||||
props.file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={commenting()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={commentLabel(range())}
|
||||
onInput={(value) => setDraft(value)}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
props.addCommentToContext({
|
||||
file: p,
|
||||
selection: range(),
|
||||
comment: value,
|
||||
origin: "file",
|
||||
})
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs.Content
|
||||
value={props.tab}
|
||||
class="mt-3 relative"
|
||||
ref={(el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded && isImage()}>
|
||||
<div class="px-6 py-4 pb-40">
|
||||
<img
|
||||
src={imageDataUrl()}
|
||||
alt={path()}
|
||||
class="max-w-full"
|
||||
onLoad={() => requestAnimationFrame(restoreScroll)}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isSvg()}>
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
{renderCode(svgContent() ?? "", "")}
|
||||
<Show when={svgPreviewUrl()}>
|
||||
<div class="flex justify-center pb-40">
|
||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isBinary()}>
|
||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="flex flex-col gap-2 max-w-md">
|
||||
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
||||
<div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
)
|
||||
}
|
||||
62
packages/app/src/pages/session/message-gesture.test.ts
Normal file
62
packages/app/src/pages/session/message-gesture.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture"
|
||||
|
||||
describe("normalizeWheelDelta", () => {
|
||||
test("converts line mode to px", () => {
|
||||
expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120)
|
||||
})
|
||||
|
||||
test("converts page mode to container height", () => {
|
||||
expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600)
|
||||
})
|
||||
|
||||
test("keeps pixel mode unchanged", () => {
|
||||
expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldMarkBoundaryGesture", () => {
|
||||
test("marks when nested scroller cannot scroll", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 0,
|
||||
scrollHeight: 300,
|
||||
clientHeight: 300,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("marks when scrolling beyond top boundary", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: -40,
|
||||
scrollTop: 10,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("marks when scrolling beyond bottom boundary", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 50,
|
||||
scrollTop: 580,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("does not mark when nested scroller can consume movement", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 200,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
21
packages/app/src/pages/session/message-gesture.ts
Normal file
21
packages/app/src/pages/session/message-gesture.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => {
|
||||
if (input.deltaMode === 1) return input.deltaY * 40
|
||||
if (input.deltaMode === 2) return input.deltaY * input.rootHeight
|
||||
return input.deltaY
|
||||
}
|
||||
|
||||
export const shouldMarkBoundaryGesture = (input: {
|
||||
delta: number
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
}) => {
|
||||
const max = input.scrollHeight - input.clientHeight
|
||||
if (max <= 1) return true
|
||||
if (!input.delta) return false
|
||||
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= 0
|
||||
|
||||
const remaining = max - input.scrollTop
|
||||
return input.delta > remaining
|
||||
}
|
||||
348
packages/app/src/pages/session/message-timeline.tsx
Normal file
348
packages/app/src/pages/session/message-timeline.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
mobileChanges: boolean
|
||||
mobileFallback: JSX.Element
|
||||
scroll: { overflow: boolean; bottom: boolean }
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: () => boolean
|
||||
isDesktop: boolean
|
||||
onScrollSpyScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
showHeader: boolean
|
||||
centered: boolean
|
||||
title?: string
|
||||
parentID?: string
|
||||
openTitleEditor: () => void
|
||||
closeTitleEditor: () => void
|
||||
saveTitleEditor: () => void | Promise<void>
|
||||
titleRef: (el: HTMLInputElement) => void
|
||||
titleState: {
|
||||
draft: string
|
||||
editing: boolean
|
||||
saving: boolean
|
||||
menuOpen: boolean
|
||||
pendingRename: boolean
|
||||
}
|
||||
onTitleDraft: (value: string) => void
|
||||
onTitleMenuOpen: (open: boolean) => void
|
||||
onTitlePendingRename: (value: boolean) => void
|
||||
onNavigateParent: () => void
|
||||
sessionID: string
|
||||
onArchiveSession: (sessionID: string) => void
|
||||
onDeleteSession: (sessionID: string) => void
|
||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
turnStart: number
|
||||
onRenderEarlier: () => void
|
||||
historyMore: boolean
|
||||
historyLoading: boolean
|
||||
onLoadEarlier: () => void
|
||||
renderedUserMessages: UserMessage[]
|
||||
anchor: (id: string) => string
|
||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
||||
onUnregisterMessage: (id: string) => void
|
||||
onFirstTurnMount?: () => void
|
||||
lastUserMessageID?: string
|
||||
expanded: Record<string, boolean>
|
||||
onToggleExpanded: (id: string) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!props.mobileChanges}
|
||||
fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
|
||||
>
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
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"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
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"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
const root = e.currentTarget
|
||||
const target = e.target instanceof Element ? e.target : undefined
|
||||
const nested = target?.closest("[data-scrollable]")
|
||||
if (!nested || nested === root) {
|
||||
props.onMarkScrollGesture(root)
|
||||
return
|
||||
}
|
||||
|
||||
if (!(nested instanceof HTMLElement)) {
|
||||
props.onMarkScrollGesture(root)
|
||||
return
|
||||
}
|
||||
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: e.deltaY,
|
||||
deltaMode: e.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
|
||||
if (
|
||||
shouldMarkBoundaryGesture({
|
||||
delta,
|
||||
scrollTop: nested.scrollTop,
|
||||
scrollHeight: nested.scrollHeight,
|
||||
clientHeight: nested.clientHeight,
|
||||
})
|
||||
) {
|
||||
props.onMarkScrollGesture(root)
|
||||
}
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
touchGesture = e.touches[0]?.clientY
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const next = e.touches[0]?.clientY
|
||||
const prev = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || prev === undefined) return
|
||||
|
||||
const delta = prev - next
|
||||
if (!delta) return
|
||||
|
||||
const root = e.currentTarget
|
||||
const target = e.target instanceof Element ? e.target : undefined
|
||||
const nested = target?.closest("[data-scrollable]")
|
||||
if (!nested || nested === root) {
|
||||
props.onMarkScrollGesture(root)
|
||||
return
|
||||
}
|
||||
|
||||
if (!(nested instanceof HTMLElement)) {
|
||||
props.onMarkScrollGesture(root)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
shouldMarkBoundaryGesture({
|
||||
delta,
|
||||
scrollTop: nested.scrollTop,
|
||||
scrollHeight: nested.scrollHeight,
|
||||
clientHeight: nested.clientHeight,
|
||||
})
|
||||
) {
|
||||
props.onMarkScrollGesture(root)
|
||||
}
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
||||
style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
|
||||
>
|
||||
<Show when={props.showHeader}>
|
||||
<div
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
"w-full": true,
|
||||
"px-4 md:px-6": true,
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-10 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={props.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={props.onNavigateParent}
|
||||
aria-label={props.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.title || props.titleState.editing}>
|
||||
<Show
|
||||
when={props.titleState.editing}
|
||||
fallback={
|
||||
<h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
|
||||
{props.title}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={props.titleRef}
|
||||
value={props.titleState.draft}
|
||||
disabled={props.titleState.saving}
|
||||
class="text-16-medium text-text-strong grow-1 min-w-0"
|
||||
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void props.saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={props.closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.sessionID}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center">
|
||||
<DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
|
||||
<Tooltip value={props.t("common.moreOptions")} placement="top">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={props.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!props.titleState.pendingRename) return
|
||||
event.preventDefault()
|
||||
props.onTitlePendingRename(false)
|
||||
props.openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
props.onTitlePendingRename(true)
|
||||
props.onTitleMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.turnStart > 0}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
|
||||
{props.t("session.messages.renderEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.historyMore}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={props.historyLoading}
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
{props.historyLoading
|
||||
? props.t("session.messages.loadingEarlier")
|
||||
: props.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={props.renderedUserMessages}>
|
||||
{(message) => {
|
||||
if (import.meta.env.DEV && props.onFirstTurnMount) {
|
||||
onMount(() => props.onFirstTurnMount?.())
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 3xl:max-w-[1200px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
sessionID={props.sessionID}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
stepsExpanded={props.expanded[message.id] ?? false}
|
||||
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
158
packages/app/src/pages/session/review-tab.tsx
Normal file
158
packages/app/src/pages/session/review-tab.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: () => FileDiff[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
onViewFile?: (file: string) => void
|
||||
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
|
||||
comments?: LineComment[]
|
||||
focusedComment?: { file: string; id: string } | null
|
||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement) => void
|
||||
classes?: {
|
||||
root?: string
|
||||
header?: string
|
||||
container?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function StickyAddButton(props: { children: JSX.Element }) {
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
let button: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const node = button
|
||||
if (!node) return
|
||||
|
||||
const scroll = node.parentElement
|
||||
if (!scroll) return
|
||||
|
||||
const handler = () => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
const scrollRect = scroll.getBoundingClientRect()
|
||||
setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
|
||||
}
|
||||
|
||||
scroll.addEventListener("scroll", handler, { passive: true })
|
||||
const observer = new ResizeObserver(handler)
|
||||
observer.observe(scroll)
|
||||
handler()
|
||||
onCleanup(() => {
|
||||
scroll.removeEventListener("scroll", handler)
|
||||
observer.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={button}
|
||||
class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
|
||||
classList={{ "border-l": stuck() }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
const readFile = async (path: string) => {
|
||||
return sdk.client.file
|
||||
.read({ path })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const s = props.view().scroll("review")
|
||||
if (!s) return
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
pending = {
|
||||
x: event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
}
|
||||
if (frame !== undefined) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
|
||||
const next = pending
|
||||
pending = undefined
|
||||
if (!next) return
|
||||
|
||||
props.view().setScroll("review", next)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.diffs().length,
|
||||
() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionReview
|
||||
title={props.title}
|
||||
empty={props.empty}
|
||||
scrollRef={(el) => {
|
||||
scroll = el
|
||||
props.onScrollRef?.(el)
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onDiffRendered={() => requestAnimationFrame(restoreScroll)}
|
||||
open={props.view().review.open()}
|
||||
onOpenChange={props.view().review.setOpen}
|
||||
classes={{
|
||||
root: props.classes?.root ?? "pb-40",
|
||||
header: props.classes?.header ?? "px-6",
|
||||
container: props.classes?.container ?? "px-6",
|
||||
}}
|
||||
diffs={props.diffs()}
|
||||
diffStyle={props.diffStyle}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
onViewFile={props.onViewFile}
|
||||
focusedFile={props.focusedFile}
|
||||
readFile={readFile}
|
||||
onLineComment={props.onLineComment}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
packages/app/src/pages/session/session-command-helpers.ts
Normal file
10
packages/app/src/pages/session/session-command-helpers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const canAddSelectionContext = (input: {
|
||||
active?: string
|
||||
pathFromTab: (tab: string) => string | undefined
|
||||
selectedLines: (path: string) => unknown
|
||||
}) => {
|
||||
if (!input.active) return false
|
||||
const path = input.pathFromTab(input.active)
|
||||
if (!path) return false
|
||||
return input.selectedLines(path) != null
|
||||
}
|
||||
36
packages/app/src/pages/session/session-mobile-tabs.tsx
Normal file
36
packages/app/src/pages/session/session-mobile-tabs.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Match, Show, Switch } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
|
||||
export function SessionMobileTabs(props: {
|
||||
open: boolean
|
||||
hasReview: boolean
|
||||
reviewCount: number
|
||||
onSession: () => void
|
||||
onChanges: () => void
|
||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
}) {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<Tabs class="h-auto">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
|
||||
{props.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="changes"
|
||||
class="w-1/2 !border-r-0"
|
||||
classes={{ button: "w-full" }}
|
||||
onClick={props.onChanges}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.hasReview}>
|
||||
{props.t("session.review.filesChanged", { count: props.reviewCount })}
|
||||
</Match>
|
||||
<Match when={true}>{props.t("session.review.change.other")}</Match>
|
||||
</Switch>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
22
packages/app/src/pages/session/session-prompt-dock.test.ts
Normal file
22
packages/app/src/pages/session/session-prompt-dock.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { questionSubtitle } from "./session-prompt-helpers"
|
||||
|
||||
describe("questionSubtitle", () => {
|
||||
const t = (key: string) => {
|
||||
if (key === "ui.common.question.one") return "question"
|
||||
if (key === "ui.common.question.other") return "questions"
|
||||
return key
|
||||
}
|
||||
|
||||
test("returns empty for zero", () => {
|
||||
expect(questionSubtitle(0, t)).toBe("")
|
||||
})
|
||||
|
||||
test("uses singular label", () => {
|
||||
expect(questionSubtitle(1, t)).toBe("1 question")
|
||||
})
|
||||
|
||||
test("uses plural label", () => {
|
||||
expect(questionSubtitle(3, t)).toBe("3 questions")
|
||||
})
|
||||
})
|
||||
137
packages/app/src/pages/session/session-prompt-dock.tsx
Normal file
137
packages/app/src/pages/session/session-prompt-dock.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { For, Show, type ComponentProps } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { BasicTool } from "@opencode-ai/ui/basic-tool"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { QuestionDock } from "@/components/question-dock"
|
||||
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
|
||||
|
||||
const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
|
||||
|
||||
export function SessionPromptDock(props: {
|
||||
centered: boolean
|
||||
questionRequest: () => { questions: unknown[] } | undefined
|
||||
permissionRequest: () => { patterns: string[]; permission: string } | undefined
|
||||
blocked: boolean
|
||||
promptReady: boolean
|
||||
handoffPrompt?: string
|
||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
responding: boolean
|
||||
onDecide: (response: "once" | "always" | "reject") => void
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-4 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.questionRequest()} keyed>
|
||||
{(req) => {
|
||||
const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
|
||||
return (
|
||||
<div data-component="tool-part-wrapper" data-question="true" class="mb-3">
|
||||
<BasicTool
|
||||
icon="bubble-5"
|
||||
locked
|
||||
defaultOpen
|
||||
trigger={{
|
||||
title: props.t("ui.tool.questions"),
|
||||
subtitle,
|
||||
}}
|
||||
/>
|
||||
<QuestionDock request={questionDockRequest(req)} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={props.permissionRequest()} keyed>
|
||||
{(perm) => (
|
||||
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
|
||||
<BasicTool
|
||||
icon="checklist"
|
||||
locked
|
||||
defaultOpen
|
||||
trigger={{
|
||||
title: props.t("notification.permission.title"),
|
||||
subtitle:
|
||||
perm.permission === "doom_loop"
|
||||
? props.t("settings.permissions.tool.doom_loop.title")
|
||||
: perm.permission,
|
||||
}}
|
||||
>
|
||||
<Show when={perm.patterns.length > 0}>
|
||||
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
|
||||
<For each={perm.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={perm.permission === "doom_loop"}>
|
||||
<div class="text-12-regular text-text-weak pb-2 px-3">
|
||||
{props.t("settings.permissions.tool.doom_loop.description")}
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
<div data-component="permission-prompt">
|
||||
<div data-slot="permission-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => props.onDecide("reject")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => props.onDecide("once")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.blocked}>
|
||||
<Show
|
||||
when={props.promptReady}
|
||||
fallback={
|
||||
<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">
|
||||
{props.handoffPrompt || props.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PromptInput
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
packages/app/src/pages/session/session-prompt-helpers.ts
Normal file
4
packages/app/src/pages/session/session-prompt-helpers.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const questionSubtitle = (count: number, t: (key: string) => string) => {
|
||||
if (count === 0) return ""
|
||||
return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
|
||||
}
|
||||
306
packages/app/src/pages/session/session-side-panel.tsx
Normal file
306
packages/app/src/pages/session/session-side-panel.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { StickyAddButton } from "@/pages/session/review-tab"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
open: boolean
|
||||
language: ReturnType<typeof useLanguage>
|
||||
layout: ReturnType<typeof useLayout>
|
||||
command: ReturnType<typeof useCommand>
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
file: ReturnType<typeof useFile>
|
||||
comments: ReturnType<typeof useComments>
|
||||
sync: ReturnType<typeof useSync>
|
||||
hasReview: boolean
|
||||
reviewCount: number
|
||||
reviewTab: boolean
|
||||
contextOpen: () => boolean
|
||||
openedTabs: () => string[]
|
||||
activeTab: () => string
|
||||
activeFileTab: () => string | undefined
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
openTab: (value: string) => void
|
||||
showAllFiles: () => void
|
||||
reviewPanel: () => JSX.Element
|
||||
messages: () => unknown[]
|
||||
visibleUserMessages: () => unknown[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
info: () => unknown
|
||||
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
||||
codeComponent: NonNullable<ValidComponent>
|
||||
addCommentToContext: (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}) => void
|
||||
activeDraggable: () => string | undefined
|
||||
onDragStart: (event: unknown) => void
|
||||
onDragEnd: () => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
fileTreeTab: () => "changes" | "all"
|
||||
setFileTreeTabValue: (value: string) => void
|
||||
diffsReady: boolean
|
||||
diffFiles: string[]
|
||||
kinds: Map<string, "add" | "del" | "mix">
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={props.language.t("session.panel.reviewAndFiles")}
|
||||
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
|
||||
>
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<Show
|
||||
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
|
||||
fallback={
|
||||
<DragDropProvider
|
||||
onDragStart={props.onDragStart}
|
||||
onDragEnd={props.onDragEnd}
|
||||
onDragOver={props.onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={props.activeTab()} onChange={props.openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={props.reviewTab}>
|
||||
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{props.language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview}>
|
||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||
{props.reviewCount}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={props.contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.tabs().close("context")}
|
||||
aria-label={props.language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{props.language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={props.openedTabs()}>
|
||||
<For each={props.openedTabs()}>
|
||||
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<StickyAddButton>
|
||||
<TooltipKeybind
|
||||
title={props.language.t("command.file.open")}
|
||||
keybind={props.command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() =>
|
||||
props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
|
||||
}
|
||||
aria-label={props.language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</StickyAddButton>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={props.reviewTab}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={props.activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{props.language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={props.contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={props.activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab
|
||||
messages={props.messages as never}
|
||||
visibleUserMessages={props.visibleUserMessages as never}
|
||||
view={props.view as never}
|
||||
info={props.info as never}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.activeFileTab()} keyed>
|
||||
{(tab) => (
|
||||
<FileTabContent
|
||||
tab={tab}
|
||||
activeTab={props.activeTab}
|
||||
tabs={props.tabs}
|
||||
view={props.view}
|
||||
handoffFiles={props.handoffFiles}
|
||||
file={props.file}
|
||||
comments={props.comments}
|
||||
language={props.language}
|
||||
codeComponent={props.codeComponent}
|
||||
addCommentToContext={props.addCommentToContext}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={props.activeDraggable()}>
|
||||
{(tab) => {
|
||||
const path = createMemo(() => props.file.pathFromTab(tab()))
|
||||
return (
|
||||
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
}
|
||||
>
|
||||
{props.reviewPanel()}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.layout.fileTree.opened()}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
class="relative shrink-0 h-full"
|
||||
style={{ width: `${props.layout.fileTree.width()}px` }}
|
||||
>
|
||||
<div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={props.fileTreeTab()}
|
||||
onChange={props.setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount}{" "}
|
||||
{props.language.t(
|
||||
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview}>
|
||||
<Show
|
||||
when={props.diffsReady}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{props.language.t("common.loading")}
|
||||
{props.language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
allowed={props.diffFiles}
|
||||
kinds={props.kinds}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
||||
{props.language.t("session.review.noChanges")}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
|
||||
<FileTree
|
||||
path=""
|
||||
modified={props.diffFiles}
|
||||
kinds={props.kinds}
|
||||
onFileClick={(node) => props.openTab(props.file.tab(node.path))}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={props.layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={props.layout.fileTree.resize}
|
||||
onCollapse={props.layout.fileTree.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</aside>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
16
packages/app/src/pages/session/terminal-label.ts
Normal file
16
packages/app/src/pages/session/terminal-label.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const terminalTabLabel = (input: {
|
||||
title?: string
|
||||
titleNumber?: number
|
||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
}) => {
|
||||
const title = input.title ?? ""
|
||||
const number = input.titleNumber ?? 0
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
const parsed = match ? Number(match[1]) : undefined
|
||||
const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
|
||||
|
||||
if (title && !isDefaultTitle) return title
|
||||
if (number > 0) return input.t("terminal.title.numbered", { number })
|
||||
if (title) return title
|
||||
return input.t("terminal.title")
|
||||
}
|
||||
25
packages/app/src/pages/session/terminal-panel.test.ts
Normal file
25
packages/app/src/pages/session/terminal-panel.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { terminalTabLabel } from "./terminal-label"
|
||||
|
||||
const t = (key: string, vars?: Record<string, string | number | boolean>) => {
|
||||
if (key === "terminal.title.numbered") return `Terminal ${vars?.number}`
|
||||
if (key === "terminal.title") return "Terminal"
|
||||
return key
|
||||
}
|
||||
|
||||
describe("terminalTabLabel", () => {
|
||||
test("returns custom title unchanged", () => {
|
||||
const label = terminalTabLabel({ title: "server", titleNumber: 3, t })
|
||||
expect(label).toBe("server")
|
||||
})
|
||||
|
||||
test("normalizes default numbered title", () => {
|
||||
const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t })
|
||||
expect(label).toBe("Terminal 2")
|
||||
})
|
||||
|
||||
test("falls back to generic title", () => {
|
||||
const label = terminalTabLabel({ title: "", titleNumber: 0, t })
|
||||
expect(label).toBe("Terminal")
|
||||
})
|
||||
})
|
||||
169
packages/app/src/pages/session/terminal-panel.tsx
Normal file
169
packages/app/src/pages/session/terminal-panel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
|
||||
import { SortableTerminalTab } from "@/components/session"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
|
||||
export function TerminalPanel(props: {
|
||||
open: boolean
|
||||
height: number
|
||||
resize: (value: number) => void
|
||||
close: () => void
|
||||
terminal: ReturnType<typeof useTerminal>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
command: ReturnType<typeof useCommand>
|
||||
handoff: () => string[]
|
||||
activeTerminalDraggable: () => string | undefined
|
||||
handleTerminalDragStart: (event: unknown) => void
|
||||
handleTerminalDragOver: (event: DragEvent) => void
|
||||
handleTerminalDragEnd: () => void
|
||||
onCloseTab: () => void
|
||||
}) {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
id="terminal-panel"
|
||||
role="region"
|
||||
aria-label={props.language.t("terminal.title")}
|
||||
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
||||
style={{ height: `${props.height}px` }}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={props.height}
|
||||
min={100}
|
||||
max={window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={props.resize}
|
||||
onCollapse={props.close}
|
||||
/>
|
||||
<Show
|
||||
when={props.terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||
<For each={props.handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{props.language.t("common.loading")}
|
||||
{props.language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">
|
||||
{props.language.t("terminal.loading")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={props.handleTerminalDragStart}
|
||||
onDragEnd={props.handleTerminalDragEnd}
|
||||
onDragOver={props.handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="alt"
|
||||
value={props.terminal.active()}
|
||||
onChange={(id) => props.terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}>
|
||||
<For each={props.terminal.all()}>
|
||||
{(pty) => (
|
||||
<SortableTerminalTab
|
||||
terminal={pty}
|
||||
onClose={() => {
|
||||
props.close()
|
||||
props.onCloseTab()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title={props.language.t("command.terminal.new")}
|
||||
keybind={props.command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={props.terminal.new}
|
||||
aria-label={props.language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={props.terminal.all()}>
|
||||
{(pty) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: props.terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<Show when={pty.id} keyed>
|
||||
<Terminal
|
||||
pty={pty}
|
||||
onCleanup={props.terminal.update}
|
||||
onConnectError={() => props.terminal.clone(pty.id)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={props.activeTerminalDraggable()}>
|
||||
{(draggedId) => {
|
||||
const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||
return (
|
||||
<Show when={pty()}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: props.language.t as (
|
||||
key: string,
|
||||
vars?: Record<string, string | number | boolean>,
|
||||
) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
44
packages/app/src/pages/session/use-session-commands.test.ts
Normal file
44
packages/app/src/pages/session/use-session-commands.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canAddSelectionContext } from "./session-command-helpers"
|
||||
|
||||
describe("canAddSelectionContext", () => {
|
||||
test("returns false without active tab", () => {
|
||||
expect(
|
||||
canAddSelectionContext({
|
||||
active: undefined,
|
||||
pathFromTab: () => "src/a.ts",
|
||||
selectedLines: () => ({ start: 1, end: 1 }),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when active tab is not a file", () => {
|
||||
expect(
|
||||
canAddSelectionContext({
|
||||
active: "context",
|
||||
pathFromTab: () => undefined,
|
||||
selectedLines: () => ({ start: 1, end: 1 }),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false without selected lines", () => {
|
||||
expect(
|
||||
canAddSelectionContext({
|
||||
active: "file://src/a.ts",
|
||||
pathFromTab: () => "src/a.ts",
|
||||
selectedLines: () => null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when file and selection exist", () => {
|
||||
expect(
|
||||
canAddSelectionContext({
|
||||
active: "file://src/a.ts",
|
||||
pathFromTab: () => "src/a.ts",
|
||||
selectedLines: () => ({ start: 1, end: 2 }),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
439
packages/app/src/pages/session/use-session-commands.tsx
Normal file
439
packages/app/src/pages/session/use-session-commands.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
import { DialogFork } from "@/components/dialog-fork"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { combineCommandSections } from "@/pages/session/helpers"
|
||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
||||
|
||||
export const useSessionCommands = (input: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
file: ReturnType<typeof useFile>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
local: ReturnType<typeof useLocal>
|
||||
permission: ReturnType<typeof usePermission>
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
terminal: ReturnType<typeof useTerminal>
|
||||
layout: ReturnType<typeof useLayout>
|
||||
params: ReturnType<typeof useParams>
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
|
||||
status: () => { type: string }
|
||||
userMessages: () => UserMessage[]
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
activeMessage: () => UserMessage | undefined
|
||||
showAllFiles: () => void
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
addSelectionToContext: (path: string, selection: FileSelection) => void
|
||||
}) => {
|
||||
const sessionCommands = createMemo(() => [
|
||||
{
|
||||
id: "session.new",
|
||||
title: input.language.t("command.session.new"),
|
||||
category: input.language.t("command.category.session"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => input.navigate(`/${input.params.dir}/session`),
|
||||
},
|
||||
])
|
||||
|
||||
const fileCommands = createMemo(() => [
|
||||
{
|
||||
id: "file.open",
|
||||
title: input.language.t("command.file.open"),
|
||||
description: input.language.t("palette.search.placeholder"),
|
||||
category: input.language.t("command.category.file"),
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
|
||||
},
|
||||
{
|
||||
id: "tab.close",
|
||||
title: input.language.t("command.tab.close"),
|
||||
category: input.language.t("command.category.file"),
|
||||
keybind: "mod+w",
|
||||
disabled: !input.tabs().active(),
|
||||
onSelect: () => {
|
||||
const active = input.tabs().active()
|
||||
if (!active) return
|
||||
input.tabs().close(active)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const contextCommands = createMemo(() => [
|
||||
{
|
||||
id: "context.addSelection",
|
||||
title: input.language.t("command.context.addSelection"),
|
||||
description: input.language.t("command.context.addSelection.description"),
|
||||
category: input.language.t("command.category.context"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext({
|
||||
active: input.tabs().active(),
|
||||
pathFromTab: input.file.pathFromTab,
|
||||
selectedLines: input.file.selectedLines,
|
||||
}),
|
||||
onSelect: () => {
|
||||
const active = input.tabs().active()
|
||||
if (!active) return
|
||||
const path = input.file.pathFromTab(active)
|
||||
if (!path) return
|
||||
|
||||
const range = input.file.selectedLines(path)
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: input.language.t("toast.context.noLineSelection.title"),
|
||||
description: input.language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const viewCommands = createMemo(() => [
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: input.language.t("command.terminal.toggle"),
|
||||
description: "",
|
||||
category: input.language.t("command.category.view"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => input.view().terminal.toggle(),
|
||||
},
|
||||
{
|
||||
id: "review.toggle",
|
||||
title: input.language.t("command.review.toggle"),
|
||||
description: "",
|
||||
category: input.language.t("command.category.view"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => input.view().reviewPanel.toggle(),
|
||||
},
|
||||
{
|
||||
id: "fileTree.toggle",
|
||||
title: input.language.t("command.fileTree.toggle"),
|
||||
description: "",
|
||||
category: input.language.t("command.category.view"),
|
||||
onSelect: () => {
|
||||
const opening = !input.layout.fileTree.opened()
|
||||
if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open()
|
||||
input.layout.fileTree.toggle()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.new",
|
||||
title: input.language.t("command.terminal.new"),
|
||||
description: input.language.t("command.terminal.new.description"),
|
||||
category: input.language.t("command.category.terminal"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (input.terminal.all().length > 0) input.terminal.new()
|
||||
input.view().terminal.open()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "steps.toggle",
|
||||
title: input.language.t("command.steps.toggle"),
|
||||
description: input.language.t("command.steps.toggle.description"),
|
||||
category: input.language.t("command.category.view"),
|
||||
keybind: "mod+e",
|
||||
slash: "steps",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => {
|
||||
const msg = input.activeMessage()
|
||||
if (!msg) return
|
||||
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const messageCommands = createMemo(() => [
|
||||
{
|
||||
id: "message.previous",
|
||||
title: input.language.t("command.message.previous"),
|
||||
description: input.language.t("command.message.previous.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "message.next",
|
||||
title: input.language.t("command.message.next"),
|
||||
description: input.language.t("command.message.next.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(1),
|
||||
},
|
||||
])
|
||||
|
||||
const agentCommands = createMemo(() => [
|
||||
{
|
||||
id: "model.choose",
|
||||
title: input.language.t("command.model.choose"),
|
||||
description: input.language.t("command.model.choose.description"),
|
||||
category: input.language.t("command.category.model"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
|
||||
},
|
||||
{
|
||||
id: "mcp.toggle",
|
||||
title: input.language.t("command.mcp.toggle"),
|
||||
description: input.language.t("command.mcp.toggle.description"),
|
||||
category: input.language.t("command.category.mcp"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
|
||||
},
|
||||
{
|
||||
id: "agent.cycle",
|
||||
title: input.language.t("command.agent.cycle"),
|
||||
description: input.language.t("command.agent.cycle.description"),
|
||||
category: input.language.t("command.category.agent"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => input.local.agent.move(1),
|
||||
},
|
||||
{
|
||||
id: "agent.cycle.reverse",
|
||||
title: input.language.t("command.agent.cycle.reverse"),
|
||||
description: input.language.t("command.agent.cycle.reverse.description"),
|
||||
category: input.language.t("command.category.agent"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => input.local.agent.move(-1),
|
||||
},
|
||||
{
|
||||
id: "model.variant.cycle",
|
||||
title: input.language.t("command.model.variant.cycle"),
|
||||
description: input.language.t("command.model.variant.cycle.description"),
|
||||
category: input.language.t("command.category.model"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => {
|
||||
input.local.model.variant.cycle()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const permissionCommands = createMemo(() => [
|
||||
{
|
||||
id: "permissions.autoaccept",
|
||||
title:
|
||||
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
|
||||
? input.language.t("command.permissions.autoaccept.disable")
|
||||
: input.language.t("command.permissions.autoaccept.enable"),
|
||||
category: input.language.t("command.category.permissions"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !input.params.id || !input.permission.permissionsEnabled(),
|
||||
onSelect: () => {
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
|
||||
showToast({
|
||||
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.title")
|
||||
: input.language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.description")
|
||||
: input.language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const sessionActionCommands = createMemo(() => [
|
||||
{
|
||||
id: "session.undo",
|
||||
title: input.language.t("command.session.undo"),
|
||||
description: input.language.t("command.session.undo.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "undo",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
if (input.status()?.type !== "idle") {
|
||||
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = input.info()?.revert?.messageID
|
||||
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = input.sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
|
||||
input.prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
|
||||
input.setActiveMessage(priorMessage)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.redo",
|
||||
title: input.language.t("command.session.redo"),
|
||||
description: input.language.t("command.session.redo.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "redo",
|
||||
disabled: !input.params.id || !input.info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = input.info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await input.sdk.client.session.unrevert({ sessionID })
|
||||
input.prompt.reset()
|
||||
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
|
||||
input.setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
|
||||
input.setActiveMessage(priorMsg)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.compact",
|
||||
title: input.language.t("command.session.compact"),
|
||||
description: input.language.t("command.session.compact.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "compact",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
if (!sessionID) return
|
||||
const model = input.local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: input.language.t("toast.model.none.title"),
|
||||
description: input.language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.fork",
|
||||
title: input.language.t("command.session.fork"),
|
||||
description: input.language.t("command.session.fork.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "fork",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: () => input.dialog.show(() => <DialogFork />),
|
||||
},
|
||||
])
|
||||
|
||||
const shareCommands = createMemo(() => {
|
||||
if (input.sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
{
|
||||
id: "session.share",
|
||||
title: input.language.t("command.session.share"),
|
||||
description: input.language.t("command.session.share.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "share",
|
||||
disabled: !input.params.id || !!input.info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
.then((res) => {
|
||||
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.success.title"),
|
||||
description: input.language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.failed.title"),
|
||||
description: input.language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.unshare",
|
||||
title: input.language.t("command.session.unshare"),
|
||||
description: input.language.t("command.session.unshare.description"),
|
||||
category: input.language.t("command.category.session"),
|
||||
slash: "unshare",
|
||||
disabled: !input.params.id || !input.info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
await input.sdk.client.session
|
||||
.unshare({ sessionID: input.params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.unshare.success.title"),
|
||||
description: input.language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.unshare.failed.title"),
|
||||
description: input.language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
input.command.register("session", () =>
|
||||
combineCommandSections([
|
||||
sessionCommands(),
|
||||
fileCommands(),
|
||||
contextCommands(),
|
||||
viewCommands(),
|
||||
messageCommands(),
|
||||
agentCommands(),
|
||||
permissionCommands(),
|
||||
sessionActionCommands(),
|
||||
shareCommands(),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { messageIdFromHash } from "./use-session-hash-scroll"
|
||||
|
||||
describe("messageIdFromHash", () => {
|
||||
test("parses hash with leading #", () => {
|
||||
expect(messageIdFromHash("#message-abc123")).toBe("abc123")
|
||||
})
|
||||
|
||||
test("parses raw hash fragment", () => {
|
||||
expect(messageIdFromHash("message-42")).toBe("42")
|
||||
})
|
||||
|
||||
test("ignores non-message anchors", () => {
|
||||
expect(messageIdFromHash("#review-panel")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
174
packages/app/src/pages/session/use-session-hash-scroll.ts
Normal file
174
packages/app/src/pages/session/use-session-hash-scroll.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { createEffect, on, onCleanup } from "solid-js"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const messageIdFromHash = (hash: string) => {
|
||||
const value = hash.startsWith("#") ? hash.slice(1) : hash
|
||||
const match = value.match(/^message-(.+)$/)
|
||||
if (!match) return
|
||||
return match[1]
|
||||
}
|
||||
|
||||
export const useSessionHashScroll = (input: {
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setTurnStart: (value: number) => void
|
||||
scheduleTurnBackfill: () => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
scheduleScrollState: (el: HTMLDivElement) => void
|
||||
consumePendingMessage: (key: string) => string | undefined
|
||||
}) => {
|
||||
const clearMessageHash = () => {
|
||||
if (!window.location.hash) return
|
||||
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
|
||||
}
|
||||
|
||||
const updateHash = (id: string) => {
|
||||
window.history.replaceState(null, "", `#${input.anchor(id)}`)
|
||||
}
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
const root = input.scroller()
|
||||
if (!root) return false
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const top = a.top - b.top + root.scrollTop
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
input.setActiveMessage(message)
|
||||
|
||||
const msgs = input.visibleUserMessages()
|
||||
const index = msgs.findIndex((m) => m.id === message.id)
|
||||
if (index !== -1 && index < input.turnStart()) {
|
||||
input.setTurnStart(index)
|
||||
input.scheduleTurnBackfill()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
if (!el) {
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
scrollToElement(next, behavior)
|
||||
})
|
||||
return
|
||||
}
|
||||
scrollToElement(el, behavior)
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
if (!el) {
|
||||
updateHash(message.id)
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
return
|
||||
}
|
||||
if (scrollToElement(el, behavior)) {
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
updateHash(message.id)
|
||||
}
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = messageIdFromHash(hash)
|
||||
if (messageId) {
|
||||
input.autoScroll.pause()
|
||||
const msg = input.visibleUserMessages().find((m) => m.id === messageId)
|
||||
if (msg) {
|
||||
scrollToMessage(msg, behavior)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.getElementById(hash)
|
||||
if (target) {
|
||||
input.autoScroll.pause()
|
||||
scrollToElement(target, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(input.sessionKey, (key) => {
|
||||
if (!input.sessionID()) return
|
||||
const messageID = input.consumePendingMessage(key)
|
||||
if (!messageID) return
|
||||
input.setPendingMessage(messageID)
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
|
||||
input.visibleUserMessages().length
|
||||
input.turnStart()
|
||||
|
||||
const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
|
||||
if (!targetId) return
|
||||
if (input.currentMessageId() === targetId) return
|
||||
|
||||
const msg = input.visibleUserMessages().find((m) => m.id === targetId)
|
||||
if (!msg) return
|
||||
|
||||
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
|
||||
input.autoScroll.pause()
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
||||
window.addEventListener("hashchange", handler)
|
||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||
})
|
||||
|
||||
return {
|
||||
clearMessageHash,
|
||||
scrollToMessage,
|
||||
applyHash,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user