brendan/better-session-id-handling
Brendan Allan 2026-03-21 22:12:51 +08:00
parent cf3df010ce
commit 32d542d1c6
No known key found for this signature in database
GPG Key ID: 41E835AEA046A32E
5 changed files with 245 additions and 253 deletions

View File

@ -310,9 +310,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const hasUserPrompt = createMemo(() => {
const id = params.id
if (!id) return false
const messages = sync.data.message[id]
const sessionID = params.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})

View File

@ -1620,7 +1620,6 @@ export default function Page() {
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey,
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
turnStart: historyWindow.turnStart,
@ -1694,48 +1693,51 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={lastUserMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
{(sessionID) => (
<Show when={lastUserMessage()}>
<MessageTimeline
sessionID={sessionID()}
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
/>
</Show>
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
/>
</Show>
)}
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />

View File

@ -29,7 +29,6 @@ import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/
import { useSessionKey } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { Optional } from "@/utils/optional"
type MessageComment = {
path: string
@ -196,6 +195,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
export function MessageTimeline(props: {
sessionID: string
mobileChanges: boolean
mobileFallback: JSX.Element
actions?: UserActions
@ -227,17 +227,17 @@ export function MessageTimeline(props: {
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
const { sessionKey } = useSessionKey()
const platform = usePlatform()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionMessages = createMemo(() => Optional.map(params.id, (id) => sync.data.message[id]) ?? emptyMessages)
const sessionMessages = createMemo(() => sync.data.message[props.sessionID] ?? emptyMessages)
const pending = createMemo(() =>
sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle)
const sessionStatus = createMemo(() => sync.data.session_status[props.sessionID] ?? idle)
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
@ -292,7 +292,7 @@ export function MessageTimeline(props: {
return undefined
})
const info = createMemo(() => Optional.map(params.id, (id) => sync.session.get(id)))
const info = createMemo(() => sync.session.get(props.sessionID))
const titleValue = createMemo(() => info()?.title)
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
@ -326,12 +326,11 @@ export function MessageTimeline(props: {
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = params.id
if (!id || req.share) return
if (req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.share({ sessionID: props.sessionID, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
@ -341,12 +340,11 @@ export function MessageTimeline(props: {
}
const unshareSession = () => {
const id = params.id
if (!id || req.unshare) return
if (req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.unshare({ sessionID: props.sessionID, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
@ -387,7 +385,6 @@ export function MessageTimeline(props: {
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
@ -401,8 +398,6 @@ export function MessageTimeline(props: {
}
const saveTitleEditor = async () => {
const id = params.id
if (!id) return
if (title.saving) return
const next = title.draft.trim()
@ -413,11 +408,11 @@ export function MessageTimeline(props: {
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.update({ sessionID: props.sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
const index = draft.session.findIndex((s) => s.id === props.sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
@ -433,7 +428,7 @@ export function MessageTimeline(props: {
}
const navigateAfterSessionRemoval = (id: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== id) return
if (props.sessionID !== id) return
if (parentID) {
navigate(`../${parentID}`)
return
@ -722,184 +717,180 @@ export function MessageTimeline(props: {
</Show>
</div>
</div>
<Show when={params.id}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(props.sessionID)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={props.sessionID} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={req.share}
>
{req.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={req.unshare}
>
{req.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={req.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</div>
)}
</Show>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={req.share}
>
{req.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={req.unshare}
>
{req.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={req.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</div>
</div>
</div>
</Show>
@ -987,7 +978,7 @@ export function MessageTimeline(props: {
</div>
</Show>
<SessionTurn
sessionID={params.id ?? ""}
sessionID={props.sessionID}
messageID={messageID}
actions={props.actions}
active={active()}

View File

@ -126,8 +126,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => {
const id = params.id
if (id) return permission.isAutoAccepting(id, sdk.directory)
const sessionID = params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
command.register("session", () => {
@ -146,8 +146,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "share",
disabled: !params.id,
onSelect: async () => {
const id = params.id
if (!id) return
if (!params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@ -199,7 +198,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const url = await sdk.client.session
.share({ sessionID: id })
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
@ -391,12 +390,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "mod+shift+a",
disabled: false,
onSelect: () => {
const id = params.id
if (id) permission.toggleAutoAccept(id, sdk.directory)
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = id
? permission.isAutoAccepting(id, sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
@ -415,16 +414,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const id = params.id
if (!id) return
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID: id }).catch(() => {})
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: id,
sessionID,
messageID: message.id,
})
const parts = sync.data.part[message.id]
@ -445,20 +444,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const id = params.id
if (!id) return
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: id })
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: id,
sessionID,
messageID: nextMessage.id,
})
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
@ -472,8 +471,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const id = params.id
if (!id) return
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
@ -483,7 +482,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return
}
await sdk.client.session.summarize({
sessionID: id,
sessionID,
modelID: model.id,
providerID: model.provider.id,
})

View File

@ -6,7 +6,7 @@ import { useSessionLayout } from "./session-layout"
export const useSessionHashScroll = (input: {
sessionKey: () => string
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
turnStart: () => number