Adam 5 місяців тому
батько
коміт
c2f78224ae

+ 89 - 78
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
 
 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) => {
   const sdk = useSDK()
   const language = useLanguage()
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     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 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 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) => {
     setStore("answers", store.tab, [answer])
     if (custom) setStore("custom", store.tab, answer)
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     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 = () => {
     if (sending()) return
     if (store.editing) commitCustom()
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                   type="button"
                   data-slot="question-progress-segment"
                   data-active={i() === store.tab}
-                  data-answered={
-                    (store.answers[i()]?.length ?? 0) > 0 ||
-                    (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
-                  }
+                  data-answered={answered(i())}
                   disabled={sending()}
                   onClick={() => jump(i())}
                   aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       </Show>
       <div data-slot="question-options">
         <For each={options()}>
-          {(opt, i) => {
-            const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
-            return (
-              <button
-                data-slot="question-option"
-                data-picked={picked()}
-                role={multi() ? "checkbox" : "radio"}
-                aria-checked={picked()}
-                disabled={sending()}
-                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>
-            )
-          }}
+          {(opt, i) => (
+            <Option
+              multi={multi()}
+              picked={picked(opt.label)}
+              label={opt.label}
+              description={opt.description}
+              disabled={sending()}
+              onClick={() => selectOption(i())}
+            />
+          )}
         </For>
 
         <Show
           when={store.editing}
           fallback={
             <button
+              type="button"
               data-slot="question-option"
               data-custom="true"
               data-picked={on()}
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
               disabled={sending()}
               onClick={customOpen}
             >
-              <span
-                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>
+              <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
               <span data-slot="question-option-main">
-                <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
-                <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
+                <span data-slot="option-label">{customLabel()}</span>
+                <span data-slot="option-description">{input() || customPlaceholder()}</span>
               </span>
             </button>
           }
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
               commitCustom()
             }}
           >
-            <span
-              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>
+            <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
             <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
-                ref={(el) =>
-                  setTimeout(() => {
-                    el.focus()
-                    el.style.height = "0px"
-                    el.style.height = `${el.scrollHeight}px`
-                  }, 0)
-                }
+                ref={focusCustom}
                 data-slot="question-custom-input"
-                placeholder={language.t("ui.question.custom.placeholder")}
+                placeholder={customPlaceholder()}
                 value={input()}
                 rows={1}
                 disabled={sending()}
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 }}
                 onInput={(e) => {
                   customUpdate(e.currentTarget.value)
-                  e.currentTarget.style.height = "0px"
-                  e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
+                  resizeInput(e.currentTarget)
                 }}
               />
             </span>

+ 142 - 131
packages/app/src/pages/session/file-tabs.tsx

@@ -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 }) {
   const file = useFile()
   const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
   }).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
 
   const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
     if (file.ready()) return (file.selectedLines(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) => {
     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: {
     file: string
     selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
     origin?: "review" | "file"
   }) => {
     const selection = selectionFromLines(input.selection)
-    const preview =
-      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 preview = input.preview ?? buildPreview(input.file, selection)
 
     const saved = comments.add({
       file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
     comment: string
   }) => {
     comments.update(input.file, input.id, input.comment)
-    const preview =
-      input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
+    const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
     prompt.context.updateComment(input.file, input.id, {
       comment: input.comment,
       ...(preview ? { preview } : {}),
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
     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 p = path()
     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)
     prev = { loaded, ready, active }
     if (!restore) return
-    queueRestore()
-  })
-
-  onCleanup(() => {
-    for (const item of codeScroll) {
-      item.removeEventListener("scroll", handleCodeScroll)
-    }
-
-    if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
-    if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
+    scrollSync.queueRestore()
   })
 
   const renderFile = (source: string) => (
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
         selectedLines={activeSelection()}
         commentedLines={commentedLines()}
         onRendered={() => {
-          queueRestore()
+          scrollSync.queueRestore()
         }}
         annotations={commentsUi.annotations()}
         renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
           mode: "auto",
           path: path(),
           current: state()?.content,
-          onLoad: queueRestore,
+          onLoad: scrollSync.queueRestore,
           onError: (args: { kind: "image" | "audio" | "svg" }) => {
             if (args.kind !== "svg") return
             showToast({
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
 
   return (
     <Tabs.Content value={props.tab} class="mt-3 relative h-full">
-      <ScrollView
-        class="h-full"
-        viewportRef={(el: HTMLDivElement) => {
-          scroll = el
-          restoreScroll()
-        }}
-        onScroll={handleScroll as any}
-      >
+      <ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
         <Switch>
           <Match when={state()?.loaded}>{renderFile(contents())}</Match>
           <Match when={state()?.loading}>

+ 440 - 368
packages/app/src/pages/session/use-session-commands.tsx

@@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
     return permission.isAutoAcceptingDirectory(sdk.directory)
   }
-  command.register("session", () => {
-    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
-                  if (body) {
-                    const textarea = document.createElement("textarea")
-                    textarea.value = value
-                    textarea.setAttribute("readonly", "")
-                    textarea.style.position = "fixed"
-                    textarea.style.opacity = "0"
-                    textarea.style.pointerEvents = "none"
-                    body.appendChild(textarea)
-                    textarea.select()
-                    const copied = document.execCommand("copy")
-                    body.removeChild(textarea)
-                    if (copied) return Promise.resolve(true)
-                  }
-
-                  const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
-                  if (!clipboard?.writeText) return Promise.resolve(false)
-                  return clipboard.writeText(value).then(
-                    () => true,
-                    () => false,
-                  )
-                }
-
-                const copy = async (url: string, existing: boolean) => {
-                  const ok = await write(url)
-                  if (!ok) {
-                    showToast({
-                      title: language.t("toast.session.share.copyFailed.title"),
-                      variant: "error",
-                    })
-                    return
-                  }
-
-                  showToast({
-                    title: existing
-                      ? language.t("session.share.copy.copied")
-                      : language.t("toast.session.share.success.title"),
-                    description: language.t("toast.session.share.success.description"),
-                    variant: "success",
-                  })
-                }
-
-                const existing = info()?.share?.url
-                if (existing) {
-                  await copy(existing, true)
-                  return
-                }
-
-                const url = await sdk.client.session
-                  .share({ sessionID: params.id })
-                  .then((res) => res.data?.share?.url)
-                  .catch(() => undefined)
-                if (!url) {
-                  showToast({
-                    title: language.t("toast.session.share.failed.title"),
-                    description: language.t("toast.session.share.failed.description"),
-                    variant: "error",
-                  })
-                  return
-                }
-
-                await copy(url, false)
-              },
-            }),
-            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: async () => {
-                if (!params.id) return
-                await sdk.client.session
-                  .unshare({ sessionID: params.id })
-                  .then(() =>
-                    showToast({
-                      title: language.t("toast.session.unshare.success.title"),
-                      description: language.t("toast.session.unshare.success.description"),
-                      variant: "success",
-                    }),
-                  )
-                  .catch(() =>
-                    showToast({
-                      title: language.t("toast.session.unshare.failed.title"),
-                      description: language.t("toast.session.unshare.failed.description"),
-                      variant: "error",
-                    }),
-                  )
-              },
-            }),
-          ]
+  const write = async (value: string) => {
+    const body = typeof document === "undefined" ? undefined : document.body
+    if (body) {
+      const textarea = document.createElement("textarea")
+      textarea.value = value
+      textarea.setAttribute("readonly", "")
+      textarea.style.position = "fixed"
+      textarea.style.opacity = "0"
+      textarea.style.pointerEvents = "none"
+      body.appendChild(textarea)
+      textarea.select()
+      const copied = document.execCommand("copy")
+      body.removeChild(textarea)
+      if (copied) return true
+    }
 
+    const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
+    if (!clipboard?.writeText) return false
+    return clipboard.writeText(value).then(
+      () => true,
+      () => false,
+    )
+  }
+
+  const copyShare = async (url: string, existing: boolean) => {
+    if (!(await write(url))) {
+      showToast({
+        title: language.t("toast.session.share.copyFailed.title"),
+        variant: "error",
+      })
+      return
+    }
+
+    showToast({
+      title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
+      description: language.t("toast.session.share.success.description"),
+      variant: "success",
+    })
+  }
+
+  const share = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    const existing = info()?.share?.url
+    if (existing) {
+      await copyShare(existing, true)
+      return
+    }
+
+    const url = await sdk.client.session
+      .share({ sessionID })
+      .then((res) => res.data?.share?.url)
+      .catch(() => undefined)
+    if (!url) {
+      showToast({
+        title: language.t("toast.session.share.failed.title"),
+        description: language.t("toast.session.share.failed.description"),
+        variant: "error",
+      })
+      return
+    }
+
+    await copyShare(url, false)
+  }
+
+  const unshare = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+
+    await sdk.client.session
+      .unshare({ sessionID })
+      .then(() =>
+        showToast({
+          title: language.t("toast.session.unshare.success.title"),
+          description: language.t("toast.session.unshare.success.description"),
+          variant: "success",
+        }),
+      )
+      .catch(() =>
+        showToast({
+          title: language.t("toast.session.unshare.failed.title"),
+          description: language.t("toast.session.unshare.failed.description"),
+          variant: "error",
+        }),
+      )
+  }
+
+  const openFile = () => {
+    void import("@/components/dialog-select-file").then((x) => {
+      dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
+    })
+  }
+
+  const closeTab = () => {
+    const tab = closableTab()
+    if (!tab) return
+    tabs().close(tab)
+  }
+
+  const addSelection = () => {
+    const tab = activeFileTab()
+    if (!tab) return
+
+    const path = file.pathFromTab(tab)
+    if (!path) return
+
+    const range = file.selectedLines(path) as SelectedLineRange | null | undefined
+    if (!range) {
+      showToast({
+        title: language.t("toast.context.noLineSelection.title"),
+        description: language.t("toast.context.noLineSelection.description"),
+      })
+      return
+    }
+
+    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.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) => {
-            dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
-          })
-        },
-      }),
-      fileCommand({
-        id: "tab.close",
-        title: language.t("command.tab.close"),
-        keybind: "mod+w",
-        disabled: !closableTab(),
-        onSelect: () => {
-          const tab = closableTab()
-          if (!tab) return
-          tabs().close(tab)
-        },
-      }),
-      contextCommand({
-        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()
-          if (!tab) return
-          const path = file.pathFromTab(tab)
-          if (!path) return
-
-          const range = file.selectedLines(path) as SelectedLineRange | null | undefined
-          if (!range) {
-            showToast({
-              title: language.t("toast.context.noLineSelection.title"),
-              description: language.t("toast.context.noLineSelection.description"),
-            })
-            return
-          }
-
-          addSelectionToContext(path, selectionFromLines(range))
-        },
-      }),
-      viewCommand({
-        id: "terminal.toggle",
-        title: language.t("command.terminal.toggle"),
-        keybind: "ctrl+`",
-        slash: "terminal",
-        onSelect: () => view().terminal.toggle(),
-      }),
-      viewCommand({
-        id: "review.toggle",
-        title: language.t("command.review.toggle"),
-        keybind: "mod+shift+r",
-        onSelect: () => view().reviewPanel.toggle(),
-      }),
-      viewCommand({
-        id: "fileTree.toggle",
-        title: language.t("command.fileTree.toggle"),
-        keybind: "mod+\\",
-        onSelect: () => layout.fileTree.toggle(),
-      }),
-      viewCommand({
-        id: "input.focus",
-        title: language.t("command.input.focus"),
-        keybind: "ctrl+l",
-        onSelect: focusInput,
-      }),
-      terminalCommand({
-        id: "terminal.new",
-        title: language.t("command.terminal.new"),
-        description: language.t("command.terminal.new.description"),
-        keybind: "ctrl+alt+t",
-        onSelect: () => {
-          if (terminal.all().length > 0) terminal.new()
-          view().terminal.open()
-        },
-      }),
-      sessionCommand({
-        id: "message.previous",
-        title: language.t("command.message.previous"),
-        description: language.t("command.message.previous.description"),
-        keybind: "mod+alt+[",
+        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: () => navigateMessageByOffset(-1),
+        onSelect: share,
       }),
       sessionCommand({
-        id: "message.next",
-        title: language.t("command.message.next"),
-        description: language.t("command.message.next.description"),
-        keybind: "mod+alt+]",
-        disabled: !params.id,
-        onSelect: () => navigateMessageByOffset(1),
-      }),
-      modelCommand({
-        id: "model.choose",
-        title: language.t("command.model.choose"),
-        description: language.t("command.model.choose.description"),
-        keybind: "mod+'",
-        slash: "model",
-        onSelect: () => {
-          void import("@/components/dialog-select-model").then((x) => {
-            dialog.show(() => <x.DialogSelectModel model={local.model} />)
-          })
-        },
-      }),
-      mcpCommand({
-        id: "mcp.toggle",
-        title: language.t("command.mcp.toggle"),
-        description: language.t("command.mcp.toggle.description"),
-        keybind: "mod+;",
-        slash: "mcp",
-        onSelect: () => {
-          void import("@/components/dialog-select-mcp").then((x) => {
-            dialog.show(() => <x.DialogSelectMcp />)
-          })
-        },
-      }),
-      agentCommand({
-        id: "agent.cycle",
-        title: language.t("command.agent.cycle"),
-        description: language.t("command.agent.cycle.description"),
-        keybind: "mod+.",
-        slash: "agent",
-        onSelect: () => local.agent.move(1),
-      }),
-      agentCommand({
-        id: "agent.cycle.reverse",
-        title: language.t("command.agent.cycle.reverse"),
-        description: language.t("command.agent.cycle.reverse.description"),
-        keybind: "shift+mod+.",
-        onSelect: () => local.agent.move(-1),
+        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,
       }),
-      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(),
-      }),
-      permissionsCommand({
-        id: "permissions.autoaccept",
-        title: isAutoAcceptActive()
-          ? language.t("command.permissions.autoaccept.disable")
-          : language.t("command.permissions.autoaccept.enable"),
-        keybind: "mod+shift+a",
-        disabled: false,
-        onSelect: () => {
-          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,
     ]
-  })
+  }
+
+  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({
+      id: "terminal.toggle",
+      title: language.t("command.terminal.toggle"),
+      keybind: "ctrl+`",
+      slash: "terminal",
+      onSelect: () => view().terminal.toggle(),
+    }),
+    viewCommand({
+      id: "review.toggle",
+      title: language.t("command.review.toggle"),
+      keybind: "mod+shift+r",
+      onSelect: () => view().reviewPanel.toggle(),
+    }),
+    viewCommand({
+      id: "fileTree.toggle",
+      title: language.t("command.fileTree.toggle"),
+      keybind: "mod+\\",
+      onSelect: () => layout.fileTree.toggle(),
+    }),
+    viewCommand({
+      id: "input.focus",
+      title: language.t("command.input.focus"),
+      keybind: "ctrl+l",
+      onSelect: focusInput,
+    }),
+  ]
+
+  const terminalCmds = () => [
+    terminalCommand({
+      id: "terminal.new",
+      title: language.t("command.terminal.new"),
+      description: language.t("command.terminal.new.description"),
+      keybind: "ctrl+alt+t",
+      onSelect: openTerminal,
+    }),
+  ]
+
+  const messageCmds = () => [
+    sessionCommand({
+      id: "message.previous",
+      title: language.t("command.message.previous"),
+      description: language.t("command.message.previous.description"),
+      keybind: "mod+alt+[",
+      disabled: !params.id,
+      onSelect: () => navigateMessageByOffset(-1),
+    }),
+    sessionCommand({
+      id: "message.next",
+      title: language.t("command.message.next"),
+      description: language.t("command.message.next.description"),
+      keybind: "mod+alt+]",
+      disabled: !params.id,
+      onSelect: () => navigateMessageByOffset(1),
+    }),
+  ]
+
+  const modelCmds = () => [
+    modelCommand({
+      id: "model.choose",
+      title: language.t("command.model.choose"),
+      description: language.t("command.model.choose.description"),
+      keybind: "mod+'",
+      slash: "model",
+      onSelect: chooseModel,
+    }),
+    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({
+      id: "mcp.toggle",
+      title: language.t("command.mcp.toggle"),
+      description: language.t("command.mcp.toggle.description"),
+      keybind: "mod+;",
+      slash: "mcp",
+      onSelect: chooseMcp,
+    }),
+  ]
+
+  const agentCmds = () => [
+    agentCommand({
+      id: "agent.cycle",
+      title: language.t("command.agent.cycle"),
+      description: language.t("command.agent.cycle.description"),
+      keybind: "mod+.",
+      slash: "agent",
+      onSelect: () => local.agent.move(1),
+    }),
+    agentCommand({
+      id: "agent.cycle.reverse",
+      title: language.t("command.agent.cycle.reverse"),
+      description: language.t("command.agent.cycle.reverse.description"),
+      keybind: "shift+mod+.",
+      onSelect: () => local.agent.move(-1),
+    }),
+  ]
+
+  const permissionsCmds = () => [
+    permissionsCommand({
+      id: "permissions.autoaccept",
+      title: isAutoAcceptActive()
+        ? language.t("command.permissions.autoaccept.disable")
+        : language.t("command.permissions.autoaccept.enable"),
+      keybind: "mod+shift+a",
+      disabled: false,
+      onSelect: toggleAutoAccept,
+    }),
+  ]
+
+  command.register("session", () => [
+    ...sessionCmds(),
+    ...shareCmds(),
+    ...fileCmds(),
+    ...contextCmds(),
+    ...viewCmds(),
+    ...terminalCmds(),
+    ...messageCmds(),
+    ...modelCmds(),
+    ...mcpCmds(),
+    ...agentCmds(),
+    ...permissionsCmds(),
+  ])
 }