chore(app): cleanup (#20062)

This commit is contained in:
Adam
2026-03-30 08:50:42 -05:00
committed by GitHub
parent 14f9e21d5c
commit c2f78224ae
3 changed files with 664 additions and 570 deletions
@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>() const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
)
}
function Option(props: {
multi: boolean
picked: boolean
label: string
description?: string
disabled: boolean
onClick: VoidFunction
}) {
return (
<button
type="button"
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
<span data-slot="question-option-main">
<span data-slot="option-label">{props.label}</span>
<Show when={props.description}>
<span data-slot="option-description">{props.description}</span>
</Show>
</span>
</button>
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return language.t("session.question.progress", { current: n, total: total() }) return language.t("session.question.progress", { current: n, total: total() })
}) })
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1) const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => { const customUpdate = (value: string, selected: boolean = on()) => {
@@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => { const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer]) setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer) if (custom) setStore("custom", store.tab, answer)
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customUpdate(input()) customUpdate(input())
} }
const resizeInput = (el: HTMLTextAreaElement) => {
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}
const focusCustom = (el: HTMLTextAreaElement) => {
setTimeout(() => {
el.focus()
resizeInput(el)
}, 0)
}
const toggleCustomMark = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
customToggle()
}
const next = () => { const next = () => {
if (sending()) return if (sending()) return
if (store.editing) commitCustom() if (store.editing) commitCustom()
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button" type="button"
data-slot="question-progress-segment" data-slot="question-progress-segment"
data-active={i() === store.tab} data-active={i() === store.tab}
data-answered={ data-answered={answered(i())}
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={sending()} disabled={sending()}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show> </Show>
<div data-slot="question-options"> <div data-slot="question-options">
<For each={options()}> <For each={options()}>
{(opt, i) => { {(opt, i) => (
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false <Option
return ( multi={multi()}
<button picked={picked(opt.label)}
data-slot="question-option" label={opt.label}
data-picked={picked()} description={opt.description}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()} disabled={sending()}
onClick={() => selectOption(i())} onClick={() => selectOption(i())}
> />
<span data-slot="question-option-check" aria-hidden="true"> )}
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For> </For>
<Show <Show
when={store.editing} when={store.editing}
fallback={ fallback={
<button <button
type="button"
data-slot="question-option" data-slot="question-option"
data-custom="true" data-custom="true"
data-picked={on()} data-picked={on()}
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
disabled={sending()} disabled={sending()}
onClick={customOpen} onClick={customOpen}
> >
<span <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main"> <span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> <span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span> <span data-slot="option-description">{input() || customPlaceholder()}</span>
</span> </span>
</button> </button>
} }
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
commitCustom() commitCustom()
}} }}
> >
<span <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main"> <span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> <span data-slot="option-label">{customLabel()}</span>
<textarea <textarea
ref={(el) => ref={focusCustom}
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input" data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")} placeholder={customPlaceholder()}
value={input()} value={input()}
rows={1} rows={1}
disabled={sending()} disabled={sending()}
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}} }}
onInput={(e) => { onInput={(e) => {
customUpdate(e.currentTarget.value) customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px" resizeInput(e.currentTarget)
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}} }}
/> />
</span> </span>
+142 -131
View File
@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
) )
} }
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const getCode = () => {
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 save = (next: ScrollPos) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
input.view().setScroll(input.tab(), out)
})
}
const onCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
save({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
}
const restore = () => {
const el = scroll
if (!el) return
const pos = input.view().scroll(input.tab())
if (!pos) return
sync()
if (code.length > 0) {
for (const item of code) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restore()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
return {
handleScroll,
queueRestore,
setViewport,
}
}
export function FileTabContent(props: { tab: string }) { export function FileTabContent(props: { tab: string }) {
const file = useFile() const file = useFile()
const comments = useComments() const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab }).activeFileTab
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null let find: FileSearchHandle | null = null
const search = { const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
}) })
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => { const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, { return previewSelectedLines(source, {
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
}) })
} }
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: { const addCommentToContext = (input: {
file: string file: string
selection: SelectedLineRange selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file" origin?: "review" | "file"
}) => { }) => {
const selection = selectionFromLines(input.selection) const selection = selectionFromLines(input.selection)
const preview = const preview = input.preview ?? buildPreview(input.file, selection)
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const saved = comments.add({ const saved = comments.add({
file: input.file, file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
comment: string comment: string
}) => { }) => {
comments.update(input.file, input.id, input.comment) comments.update(input.file, input.id, input.comment)
const preview = const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, { prompt.context.updateComment(input.file, input.id, {
comment: input.comment, comment: input.comment,
...(preview ? { preview } : {}), ...(preview ? { preview } : {}),
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus()) requestAnimationFrame(() => 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
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 = 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 queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const cancelCommenting = () => { const cancelCommenting = () => {
const p = path() const p = path()
if (p) file.setSelectedLines(p, null) if (p) file.setSelectedLines(p, null)
@@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active } prev = { loaded, ready, active }
if (!restore) return if (!restore) return
queueRestore() scrollSync.queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
}) })
const renderFile = (source: string) => ( const renderFile = (source: string) => (
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()} selectedLines={activeSelection()}
commentedLines={commentedLines()} commentedLines={commentedLines()}
onRendered={() => { onRendered={() => {
queueRestore() scrollSync.queueRestore()
}} }}
annotations={commentsUi.annotations()} annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation} renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto", mode: "auto",
path: path(), path: path(),
current: state()?.content, current: state()?.content,
onLoad: queueRestore, onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => { onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return if (args.kind !== "svg") return
showToast({ showToast({
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
return ( return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full"> <Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView <ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<Switch> <Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match> <Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}> <Match when={state()?.loading}>
@@ -128,25 +128,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory)
} }
command.register("session", () => { const write = async (value: string) => {
const share =
sync.data.config.share === "disabled"
? []
: [
sessionCommand({
id: "session.share",
title: info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: async () => {
if (!params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body const body = typeof document === "undefined" ? undefined : document.body
if (body) { if (body) {
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
@@ -159,20 +141,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
textarea.select() textarea.select()
const copied = document.execCommand("copy") const copied = document.execCommand("copy")
body.removeChild(textarea) body.removeChild(textarea)
if (copied) return Promise.resolve(true) if (copied) return true
} }
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false) if (!clipboard?.writeText) return false
return clipboard.writeText(value).then( return clipboard.writeText(value).then(
() => true, () => true,
() => false, () => false,
) )
} }
const copy = async (url: string, existing: boolean) => { const copyShare = async (url: string, existing: boolean) => {
const ok = await write(url) if (!(await write(url))) {
if (!ok) {
showToast({ showToast({
title: language.t("toast.session.share.copyFailed.title"), title: language.t("toast.session.share.copyFailed.title"),
variant: "error", variant: "error",
@@ -181,22 +162,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
showToast({ showToast({
title: existing title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"), description: language.t("toast.session.share.success.description"),
variant: "success", variant: "success",
}) })
} }
const share = async () => {
const sessionID = params.id
if (!sessionID) return
const existing = info()?.share?.url const existing = info()?.share?.url
if (existing) { if (existing) {
await copy(existing, true) await copyShare(existing, true)
return return
} }
const url = await sdk.client.session const url = await sdk.client.session
.share({ sessionID: params.id }) .share({ sessionID })
.then((res) => res.data?.share?.url) .then((res) => res.data?.share?.url)
.catch(() => undefined) .catch(() => undefined)
if (!url) { if (!url) {
@@ -208,19 +191,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return return
} }
await copy(url, false) await copyShare(url, false)
}, }
}),
sessionCommand({ const unshare = async () => {
id: "session.unshare", const sessionID = params.id
title: language.t("command.session.unshare"), if (!sessionID) return
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session await sdk.client.session
.unshare({ sessionID: params.id }) .unshare({ sessionID })
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.success.title"), title: language.t("toast.session.unshare.success.title"),
@@ -235,50 +214,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
variant: "error", variant: "error",
}), }),
) )
}, }
}),
]
return [ const openFile = () => {
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => {
void import("@/components/dialog-select-file").then((x) => { void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />) dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
}) })
}, }
}),
fileCommand({ const closeTab = () => {
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: () => {
const tab = closableTab() const tab = closableTab()
if (!tab) return if (!tab) return
tabs().close(tab) tabs().close(tab)
}, }
}),
contextCommand({ const addSelection = () => {
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: () => {
const tab = activeFileTab() const tab = activeFileTab()
if (!tab) return if (!tab) return
const path = file.pathFromTab(tab) const path = file.pathFromTab(tab)
if (!path) return if (!path) return
@@ -292,8 +245,209 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
addSelectionToContext(path, selectionFromLines(range)) addSelectionToContext(path, selectionFromLines(range))
}, }
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
}
const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
}
const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
}
const toggleAutoAccept = () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const prev = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(prev)
}
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID)
if (!next) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
return
}
await sdk.client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
}
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
}
const fork = () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
}
const shareCmds = () => {
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: share,
}), }),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: unshare,
}),
]
}
const sessionCmds = () => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
]
const fileCmds = () => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: closeTab,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: addSelection,
}),
]
const viewCmds = () => [
viewCommand({ viewCommand({
id: "terminal.toggle", id: "terminal.toggle",
title: language.t("command.terminal.toggle"), title: language.t("command.terminal.toggle"),
@@ -319,16 +473,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "ctrl+l", keybind: "ctrl+l",
onSelect: focusInput, onSelect: focusInput,
}), }),
]
const terminalCmds = () => [
terminalCommand({ terminalCommand({
id: "terminal.new", id: "terminal.new",
title: language.t("command.terminal.new"), title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"), description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t", keybind: "ctrl+alt+t",
onSelect: () => { onSelect: openTerminal,
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}), }),
]
const messageCmds = () => [
sessionCommand({ sessionCommand({
id: "message.previous", id: "message.previous",
title: language.t("command.message.previous"), title: language.t("command.message.previous"),
@@ -345,30 +502,38 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !params.id, disabled: !params.id,
onSelect: () => navigateMessageByOffset(1), onSelect: () => navigateMessageByOffset(1),
}), }),
]
const modelCmds = () => [
modelCommand({ modelCommand({
id: "model.choose", id: "model.choose",
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"), description: language.t("command.model.choose.description"),
keybind: "mod+'", keybind: "mod+'",
slash: "model", slash: "model",
onSelect: () => { onSelect: chooseModel,
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
},
}), }),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
]
const mcpCmds = () => [
mcpCommand({ mcpCommand({
id: "mcp.toggle", id: "mcp.toggle",
title: language.t("command.mcp.toggle"), title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"), description: language.t("command.mcp.toggle.description"),
keybind: "mod+;", keybind: "mod+;",
slash: "mcp", slash: "mcp",
onSelect: () => { onSelect: chooseMcp,
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
},
}), }),
]
const agentCmds = () => [
agentCommand({ agentCommand({
id: "agent.cycle", id: "agent.cycle",
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
@@ -384,13 +549,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1), onSelect: () => local.agent.move(-1),
}), }),
modelCommand({ ]
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"), const permissionsCmds = () => [
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({ permissionsCommand({
id: "permissions.autoaccept", id: "permissions.autoaccept",
title: isAutoAcceptActive() title: isAutoAcceptActive()
@@ -398,110 +559,21 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
: language.t("command.permissions.autoaccept.enable"), : language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a", keybind: "mod+shift+a",
disabled: false, disabled: false,
onSelect: () => { onSelect: toggleAutoAccept,
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}), }),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
},
}),
...share,
] ]
})
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
...terminalCmds(),
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...agentCmds(),
...permissionsCmds(),
])
} }