mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-10 02:44:21 +00:00
Compare commits
12 Commits
github-v1.
...
github-v1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b298a233e | ||
|
|
6f43d03043 | ||
|
|
c868a4088d | ||
|
|
83d8a88c90 | ||
|
|
268f37f8c9 | ||
|
|
b0aaf04957 | ||
|
|
b7875256f3 | ||
|
|
7bc47fb904 | ||
|
|
5cf8e54372 | ||
|
|
7437ccd6f4 | ||
|
|
4bf882ba81 | ||
|
|
d5dcc55a47 |
@@ -26,6 +26,10 @@ inputs:
|
||||
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
|
||||
required: false
|
||||
|
||||
oidc_base_url:
|
||||
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -62,3 +66,4 @@ runs:
|
||||
PROMPT: ${{ inputs.prompt }}
|
||||
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
|
||||
MENTIONS: ${{ inputs.mentions }}
|
||||
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
|
||||
|
||||
@@ -39,9 +39,9 @@ const url =
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary fallback={ErrorPage}>
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ErrorBoundary fallback={ErrorPage}>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
@@ -82,7 +82,7 @@ export function App() {
|
||||
</DiffComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</MetaProvider>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
</MetaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand, formatKeybind } from "@/context/command"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@opencode-ai/util/identifier"
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
@@ -99,6 +100,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
placeholder: number
|
||||
dragging: boolean
|
||||
imageAttachments: ImageAttachmentPart[]
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
@@ -106,6 +109,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||
dragging: false,
|
||||
imageAttachments: [],
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
})
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
@@ -133,10 +138,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||
const length = position === "start" ? 0 : promptLength(p)
|
||||
setStore("applyingHistory", true)
|
||||
prompt.set(p, length)
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, length)
|
||||
setStore("applyingHistory", false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -427,21 +434,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const rawParts = parseFromDOM()
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||
const shouldReset = trimmed.length === 0 && !hasNonText
|
||||
|
||||
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
||||
const slashMatch = rawText.match(/^\/(\S*)$/)
|
||||
if (shouldReset) {
|
||||
setStore("popover", null)
|
||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
if (prompt.dirty()) {
|
||||
prompt.set(DEFAULT_PROMPT, 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (atMatch) {
|
||||
onInput(atMatch[1])
|
||||
setStore("popover", "file")
|
||||
} else if (slashMatch) {
|
||||
slashOnInput(slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
const shellMode = store.mode === "shell"
|
||||
|
||||
if (!shellMode) {
|
||||
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
||||
const slashMatch = rawText.match(/^\/(\S*)$/)
|
||||
|
||||
if (atMatch) {
|
||||
onInput(atMatch[1])
|
||||
setStore("popover", "file")
|
||||
} else if (slashMatch) {
|
||||
slashOnInput(slashMatch[1])
|
||||
setStore("popover", "slash")
|
||||
} else {
|
||||
setStore("popover", null)
|
||||
}
|
||||
} else {
|
||||
setStore("popover", null)
|
||||
}
|
||||
|
||||
if (store.historyIndex >= 0) {
|
||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
@@ -579,6 +607,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "!" && store.mode === "normal") {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
if (cursorPosition === 0) {
|
||||
setStore("mode", "shell")
|
||||
setStore("popover", null)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (store.mode === "shell") {
|
||||
const { collapsed, cursorPosition, textLength } = getCaretState()
|
||||
if (event.key === "Escape") {
|
||||
setStore("mode", "normal")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
||||
setStore("mode", "normal")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
if (store.popover === "file") {
|
||||
onKeyDown(event)
|
||||
@@ -665,6 +716,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||
: ""
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${absolute}${query}`,
|
||||
@@ -682,16 +734,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
}))
|
||||
|
||||
const isShellMode = store.mode === "shell"
|
||||
tabs().setActive(undefined)
|
||||
editorRef.innerHTML = ""
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
setStore("imageAttachments", [])
|
||||
setStore("mode", "normal")
|
||||
|
||||
const model = {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
}
|
||||
const agent = local.agent.current()!.name
|
||||
|
||||
if (isShellMode) {
|
||||
sdk.client.session.shell({
|
||||
sessionID: existing.id,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
@@ -702,27 +773,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
sessionID: existing.id,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent: local.agent.current()!.name,
|
||||
model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
const messageID = Identifier.ascending("message")
|
||||
const textPart = {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text" as const,
|
||||
text,
|
||||
}
|
||||
const agent = local.agent.current()!.name
|
||||
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
|
||||
const optimisticParts = requestParts.map((part) => ({
|
||||
...part,
|
||||
sessionID: existing.id,
|
||||
messageID,
|
||||
}))
|
||||
|
||||
sync.session.addOptimisticMessage({
|
||||
sessionID: existing.id,
|
||||
text,
|
||||
parts: [
|
||||
{ type: "text", text } as import("@opencode-ai/sdk/v2/client").Part,
|
||||
...(fileAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
|
||||
...(imageAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
|
||||
],
|
||||
messageID,
|
||||
parts: optimisticParts,
|
||||
agent,
|
||||
model,
|
||||
})
|
||||
@@ -731,14 +805,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
sessionID: existing.id,
|
||||
agent,
|
||||
model,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
...fileAttachmentParts,
|
||||
...imageAttachmentParts,
|
||||
],
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -879,34 +947,50 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
classList={{
|
||||
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
||||
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
|
||||
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
|
||||
{store.mode === "shell"
|
||||
? "Enter shell command..."
|
||||
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative p-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
/>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
|
||||
}
|
||||
>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<div class="flex items-center gap-2 px-2 h-6">
|
||||
<Icon name="console" size="small" class="text-icon-primary" />
|
||||
<span class="text-12-regular text-text-primary">Shell</span>
|
||||
<span class="text-12-regular text-text-weak">esc to exit</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
/>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
dialog.show(() =>
|
||||
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
||||
)
|
||||
}
|
||||
>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 absolute right-2 bottom-2">
|
||||
<input
|
||||
|
||||
@@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
data-prevent-autofocus
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"size-full px-6 py-3 font-mono": true,
|
||||
|
||||
@@ -107,7 +107,7 @@ function createGlobalSync() {
|
||||
.slice()
|
||||
.filter((s) => !s.time.archived)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
// Include sessions up to the limit, plus any updated in the last hour
|
||||
// Include up to the limit, plus any updated in the last 4 hours
|
||||
const sessions = nonArchived.filter((s, i) => {
|
||||
if (i < store.limit) return true
|
||||
const updated = new Date(s.time.updated).getTime()
|
||||
|
||||
@@ -33,14 +33,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
text: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
}) {
|
||||
const messageID = crypto.randomUUID()
|
||||
const message: Message = {
|
||||
id: messageID,
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
@@ -53,15 +52,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (!messages) {
|
||||
draft.message[input.sessionID] = [message]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[messageID] = input.parts.map((part, i) => ({
|
||||
...part,
|
||||
id: `${messageID}-${i}`,
|
||||
sessionID: input.sessionID,
|
||||
messageID,
|
||||
}))
|
||||
draft.part[input.messageID] = input.parts.slice()
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
|
||||
:root {
|
||||
html,
|
||||
body {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ interface ErrorPageProps {
|
||||
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const platform = usePlatform()
|
||||
return (
|
||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center">
|
||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
<Logo class="h-8 w-auto text-text-strong" />
|
||||
<Logo class="w-58.5 opacity-12 shrink-0" />
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<h1 class="text-lg font-medium text-text-strong">Something went wrong</h1>
|
||||
<p class="text-sm text-text-weak">An error occurred while loading the application.</p>
|
||||
|
||||
@@ -122,10 +122,18 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function projectSessions(directory: string) {
|
||||
if (!directory) return []
|
||||
const sessions = globalSync
|
||||
.child(directory)[0]
|
||||
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
return flattenSessions(sessions ?? [])
|
||||
}
|
||||
|
||||
const currentSessions = createMemo(() => {
|
||||
if (!params.dir) return []
|
||||
const directory = base64Decode(params.dir)
|
||||
return flattenSessions(globalSync.child(directory)[0].session ?? [])
|
||||
return projectSessions(directory)
|
||||
})
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
@@ -162,7 +170,7 @@ export default function Layout(props: ParentProps) {
|
||||
const nextProject = projects[nextProjectIndex]
|
||||
if (!nextProject) return
|
||||
|
||||
const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? [])
|
||||
const nextProjectSessions = projectSessions(nextProject.worktree)
|
||||
if (nextProjectSessions.length === 0) {
|
||||
navigateToProject(nextProject.worktree)
|
||||
return
|
||||
@@ -350,7 +358,7 @@ export default function Layout(props: ParentProps) {
|
||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
return (
|
||||
<div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
|
||||
<div class="relative size-5 shrink-0 rounded-sm">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||
@@ -511,7 +519,9 @@ export default function Layout(props: ParentProps) {
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||
const sessions = createMemo(() => store.session ?? [])
|
||||
const sessions = createMemo(() =>
|
||||
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
|
||||
)
|
||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||
const childSessionsByParent = createMemo(() => {
|
||||
const map = new Map<string, Session[]>()
|
||||
@@ -526,7 +536,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
|
||||
const loadMoreSessions = async () => {
|
||||
setProjectStore("limit", (limit) => limit + 10)
|
||||
setProjectStore("limit", (limit) => limit + 5)
|
||||
await globalSync.project.loadSessions(props.project.worktree)
|
||||
}
|
||||
const [expanded, setExpanded] = createSignal(true)
|
||||
|
||||
@@ -327,11 +327,15 @@ export default function Page() {
|
||||
])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
|
||||
const activeElement = document.activeElement as HTMLElement | undefined
|
||||
if (activeElement) {
|
||||
const isProtected = activeElement.closest("[data-prevent-autofocus]")
|
||||
const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
||||
if (isProtected || isInput) return
|
||||
}
|
||||
if (dialog.active) return
|
||||
|
||||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (activeElement === inputRef) {
|
||||
if (event.key === "Escape") inputRef?.blur()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ export const GithubRunCommand = cmd({
|
||||
const { providerID, modelID } = normalizeModel()
|
||||
const runId = normalizeRunId()
|
||||
const share = normalizeShare()
|
||||
const oidcBaseUrl = normalizeOidcBaseUrl()
|
||||
const { owner, repo } = context.repo
|
||||
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
|
||||
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
|
||||
@@ -572,6 +573,12 @@ export const GithubRunCommand = cmd({
|
||||
throw new Error(`Invalid use_github_token value: ${value}. Must be a boolean.`)
|
||||
}
|
||||
|
||||
function normalizeOidcBaseUrl(): string {
|
||||
const value = process.env["OIDC_BASE_URL"]
|
||||
if (!value) return "https://api.opencode.ai"
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function isIssueCommentEvent(
|
||||
event: IssueCommentEvent | PullRequestReviewCommentEvent,
|
||||
): event is IssueCommentEvent {
|
||||
@@ -809,14 +816,14 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
async function exchangeForAppToken(token: string) {
|
||||
const response = token.startsWith("github_pat_")
|
||||
? await fetch("https://api.opencode.ai/exchange_github_app_token_with_pat", {
|
||||
? await fetch(`${oidcBaseUrl}/exchange_github_app_token_with_pat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ owner, repo }),
|
||||
})
|
||||
: await fetch("https://api.opencode.ai/exchange_github_app_token", {
|
||||
: await fetch(`${oidcBaseUrl}/exchange_github_app_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
||||
@@ -270,6 +270,11 @@ export function Autocomplete(props: {
|
||||
description: "jump to message",
|
||||
onSelect: () => command.trigger("session.timeline"),
|
||||
},
|
||||
{
|
||||
display: "/fork",
|
||||
description: "fork from message",
|
||||
onSelect: () => command.trigger("session.fork"),
|
||||
},
|
||||
{
|
||||
display: "/thinking",
|
||||
description: "toggle thinking visibility",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createMemo, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import type { TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x) => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
) as TextPart
|
||||
if (!part) continue
|
||||
result.push({
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: async (dialog) => {
|
||||
const forked = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: message.id,
|
||||
})
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
})
|
||||
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
|
||||
}
|
||||
@@ -24,7 +24,9 @@ export function DialogTimeline(props: {
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
|
||||
const part = (sync.data.part[message.id] ?? []).find(
|
||||
(x) => x.type === "text" && !x.synthetic && !x.ignored,
|
||||
) as TextPart
|
||||
if (!part) continue
|
||||
result.push({
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
|
||||
@@ -53,6 +53,7 @@ import { iife } from "@/util/iife"
|
||||
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
||||
import { DialogPrompt } from "@tui/ui/dialog-prompt"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
@@ -295,6 +296,25 @@ export function Session() {
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Fork from message",
|
||||
value: "session.fork",
|
||||
keybind: "session_fork",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogForkFromTimeline
|
||||
onMove={(messageID) => {
|
||||
const child = scroll.getChildren().find((child) => {
|
||||
return child.id === messageID
|
||||
})
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Compact session",
|
||||
value: "session.compact",
|
||||
|
||||
@@ -440,6 +440,8 @@ export namespace Config {
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
|
||||
session_fork: z.string().optional().default("none").describe("Fork session from message"),
|
||||
session_rename: z.string().optional().default("none").describe("Rename session"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
|
||||
@@ -1,73 +1,19 @@
|
||||
import z from "zod"
|
||||
import { randomBytes } from "crypto"
|
||||
import { Identifier as SharedIdentifier } from "@opencode-ai/util/identifier"
|
||||
|
||||
export namespace Identifier {
|
||||
const prefixes = {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
} as const
|
||||
export type Prefix = SharedIdentifier.Prefix
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
return z.string().startsWith(prefixes[prefix])
|
||||
export const schema = (prefix: Prefix) => SharedIdentifier.schema(prefix)
|
||||
|
||||
export function ascending(prefix: Prefix, given?: string) {
|
||||
return SharedIdentifier.ascending(prefix, given)
|
||||
}
|
||||
|
||||
const LENGTH = 26
|
||||
|
||||
// State for monotonic ID generation
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, false, given)
|
||||
export function descending(prefix: Prefix, given?: string) {
|
||||
return SharedIdentifier.descending(prefix, given)
|
||||
}
|
||||
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, true, given)
|
||||
}
|
||||
|
||||
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
|
||||
if (!given) {
|
||||
return create(prefix, descending)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
return given
|
||||
}
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
let result = ""
|
||||
const bytes = randomBytes(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i] % 62]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
|
||||
const currentTimestamp = timestamp ?? Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||
|
||||
now = descending ? ~now : now
|
||||
|
||||
const timeBytes = Buffer.alloc(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
export function create(prefix: Prefix, descending: boolean, timestamp?: number) {
|
||||
return SharedIdentifier.createPrefixed(prefix, descending, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ export namespace ModelsDev {
|
||||
const result = await fetch("https://models.dev/api.json", {
|
||||
headers: {
|
||||
"User-Agent": Installation.USER_AGENT,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
},
|
||||
signal: AbortSignal.timeout(10 * 1000),
|
||||
}).catch((e) => {
|
||||
|
||||
@@ -1333,6 +1333,20 @@ export namespace SessionPrompt {
|
||||
if (input.model) return Provider.parseModel(input.model)
|
||||
return await lastModel(input.sessionID)
|
||||
})()
|
||||
|
||||
try {
|
||||
await Provider.getModel(model.providerID, model.modelID)
|
||||
} catch (e) {
|
||||
if (Provider.ModelNotFoundError.isInstance(e)) {
|
||||
const { providerID, modelID, suggestions } = e.data
|
||||
const hint = suggestions?.length ? ` Did you mean: ${suggestions.join(", ")}?` : ""
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(),
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
const agent = await Agent.get(agentName)
|
||||
|
||||
const parts =
|
||||
|
||||
@@ -842,6 +842,14 @@ export type KeybindsConfig = {
|
||||
* Show session timeline
|
||||
*/
|
||||
session_timeline?: string
|
||||
/**
|
||||
* Fork session from message
|
||||
*/
|
||||
session_fork?: string
|
||||
/**
|
||||
* Rename session
|
||||
*/
|
||||
session_rename?: string
|
||||
/**
|
||||
* Share current session
|
||||
*/
|
||||
|
||||
@@ -7077,6 +7077,16 @@
|
||||
"default": "<leader>g",
|
||||
"type": "string"
|
||||
},
|
||||
"session_fork": {
|
||||
"description": "Fork session from message",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"session_rename": {
|
||||
"description": "Rename session",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"session_share": {
|
||||
"description": "Share current session",
|
||||
"default": "none",
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Checkbox(props: CheckboxProps) {
|
||||
<Kobalte.Control data-slot="checkbox-checkbox-control">
|
||||
<Kobalte.Indicator data-slot="checkbox-checkbox-indicator">
|
||||
{local.icon || (
|
||||
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 12 12" fill="none" width="10" height="10" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 7.17905L5.02703 8.85135L9 3.5"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -152,9 +152,22 @@
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
[data-component="markdown"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
&[data-scrollable] {
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface MessagePartProps {
|
||||
part: PartType
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export type PartComponent = Component<MessagePartProps>
|
||||
@@ -208,7 +209,13 @@ export function Part(props: MessagePartProps) {
|
||||
const component = createMemo(() => PART_MAPPING[props.part.type])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
|
||||
<Dynamic
|
||||
component={component()}
|
||||
part={props.part}
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -219,6 +226,7 @@ export interface ToolProps {
|
||||
tool: string
|
||||
output?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export type ToolComponent = Component<ToolProps>
|
||||
@@ -286,6 +294,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
metadata={metadata}
|
||||
output={part.state.status === "completed" ? part.state.output : undefined}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -326,6 +335,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="glasses"
|
||||
trigger={{
|
||||
title: "Read",
|
||||
@@ -340,7 +350,11 @@ ToolRegistry.register({
|
||||
name: "list",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="bullet-list"
|
||||
trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
@@ -358,6 +372,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Glob",
|
||||
@@ -385,6 +400,7 @@ ToolRegistry.register({
|
||||
if (props.input.include) args.push("include=" + props.input.include)
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Grep",
|
||||
@@ -409,6 +425,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="window-cursor"
|
||||
trigger={{
|
||||
title: "Webfetch",
|
||||
@@ -438,6 +455,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="task"
|
||||
trigger={{
|
||||
title: `${props.input.subagent_type || props.tool} Agent`,
|
||||
@@ -462,6 +480,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
@@ -485,6 +504,7 @@ ToolRegistry.register({
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
@@ -534,6 +554,7 @@ ToolRegistry.register({
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
@@ -575,6 +596,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
icon="checklist"
|
||||
trigger={{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Swi
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Typewriter } from "./typewriter"
|
||||
import { Message } from "./message-part"
|
||||
import { Message, Part } from "./message-part"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
@@ -35,29 +35,133 @@ export function SessionTurn(
|
||||
) {
|
||||
const data = useData()
|
||||
const diffComponent = useDiffComponent()
|
||||
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
|
||||
const messages = createMemo(() => data.store.message[props.sessionID] ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
|
||||
const lastUserMessage = createMemo(() => userMessages().at(-1)!)
|
||||
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)!)
|
||||
const status = createMemo(
|
||||
() =>
|
||||
data.store.session_status[props.sessionID] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle" && message()?.id === userMessages().at(-1)?.id)
|
||||
const working = createMemo(() => status().type !== "idle" && message().id === lastUserMessage().id)
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
|
||||
const assistantMessages = createMemo(() => {
|
||||
return messages().filter((m) => m.role === "assistant" && m.parentID == message().id) as AssistantMessage[]
|
||||
})
|
||||
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
|
||||
const parts = createMemo(() => data.store.part[message().id])
|
||||
const lastTextPart = createMemo(() =>
|
||||
assistantParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
const summary = createMemo(() => message().summary?.body)
|
||||
const response = createMemo(() => lastTextPart()?.text)
|
||||
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
assistantParts().findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
p.tool === "task" &&
|
||||
p.state &&
|
||||
"metadata" in p.state &&
|
||||
p.state.metadata &&
|
||||
p.state.metadata.sessionId &&
|
||||
p.state.status === "running",
|
||||
) as ToolPart,
|
||||
)
|
||||
const resolvedParts = createMemo(() => {
|
||||
let resolved = assistantParts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
|
||||
(m) => m.role === "assistant",
|
||||
)
|
||||
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const last = lastPart()
|
||||
if (!last) return undefined
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
case "task":
|
||||
return "Delegating work"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning next steps"
|
||||
case "read":
|
||||
return "Gathering context"
|
||||
case "list":
|
||||
case "grep":
|
||||
case "glob":
|
||||
return "Searching the codebase"
|
||||
case "webfetch":
|
||||
return "Searching the web"
|
||||
case "edit":
|
||||
case "write":
|
||||
return "Making edits"
|
||||
case "bash":
|
||||
return "Running commands"
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
const text = last.text ?? ""
|
||||
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
|
||||
if (match) return `Thinking · ${match[1].trim()}`
|
||||
return "Thinking"
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts"
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const hasDiffs = createMemo(() => message().summary?.diffs?.length)
|
||||
const isShellMode = createMemo(() => {
|
||||
if (parts().some((p) => p.type !== "text" || !p.synthetic)) return false
|
||||
if (assistantParts().length !== 1) return false
|
||||
const assistantPart = assistantParts()[0]
|
||||
if (assistantPart.type !== "tool") return false
|
||||
if (assistantPart.tool !== "bash") return false
|
||||
return true
|
||||
})
|
||||
|
||||
function duration() {
|
||||
const completed = lastAssistantMessage()?.time.completed
|
||||
const from = DateTime.fromMillis(message().time.created)
|
||||
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
|
||||
const interval = Interval.fromDateTimes(from, to)
|
||||
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
|
||||
|
||||
return interval.toDuration(unit).normalize().toHuman({
|
||||
notation: "compact",
|
||||
unitDisplay: "narrow",
|
||||
compactDisplay: "short",
|
||||
showZeros: false,
|
||||
})
|
||||
}
|
||||
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let lastScrollTop = 0
|
||||
const [state, setState] = createStore({
|
||||
const [store, setStore] = createStore({
|
||||
contentRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
||||
@@ -65,418 +169,312 @@ export function SessionTurn(
|
||||
userScrolled: false,
|
||||
stickyHeaderHeight: 0,
|
||||
retrySeconds: 0,
|
||||
status: rawStatus(),
|
||||
stepsExpanded: props.stepsExpanded ?? working(),
|
||||
duration: duration(),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const r = retry()
|
||||
if (!r) {
|
||||
setState("retrySeconds", 0)
|
||||
setStore("retrySeconds", 0)
|
||||
return
|
||||
}
|
||||
const updateSeconds = () => {
|
||||
const next = r.next
|
||||
if (next) setState("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
|
||||
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
|
||||
}
|
||||
updateSeconds()
|
||||
|
||||
const timer = setInterval(updateSeconds, 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef || state.autoScrolled) return
|
||||
if (!scrollRef || store.autoScrolled) return
|
||||
const { scrollTop } = scrollRef
|
||||
// only mark as user scrolled if they actively scrolled upward
|
||||
// content growth increases scrollHeight but never decreases scrollTop
|
||||
const scrolledUp = scrollTop < lastScrollTop - 10
|
||||
if (scrolledUp && working()) {
|
||||
setState("userScrolled", true)
|
||||
setStore("userScrolled", true)
|
||||
}
|
||||
lastScrollTop = scrollTop
|
||||
}
|
||||
|
||||
function handleInteraction() {
|
||||
if (working()) {
|
||||
setState("userScrolled", true)
|
||||
}
|
||||
if (working()) setStore("userScrolled", true)
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollRef || state.userScrolled || !working()) return
|
||||
setState("autoScrolled", true)
|
||||
if (!scrollRef || store.userScrolled || !working()) return
|
||||
setStore("autoScrolled", true)
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
|
||||
requestAnimationFrame(() => {
|
||||
lastScrollTop = scrollRef?.scrollTop ?? 0
|
||||
setState("autoScrolled", false)
|
||||
setStore("autoScrolled", false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
createResizeObserver(() => state.contentRef, scrollToBottom)
|
||||
createResizeObserver(() => store.contentRef, scrollToBottom)
|
||||
|
||||
createEffect(() => {
|
||||
if (!working()) {
|
||||
setState("userScrolled", false)
|
||||
}
|
||||
if (!working()) setStore("userScrolled", false)
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => state.stickyTitleRef,
|
||||
() => store.stickyTitleRef,
|
||||
({ height }) => {
|
||||
const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
|
||||
setState("stickyHeaderHeight", height + triggerHeight + 8)
|
||||
const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
|
||||
setStore("stickyHeaderHeight", height + triggerHeight + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createResizeObserver(
|
||||
() => state.stickyTriggerRef,
|
||||
() => store.stickyTriggerRef,
|
||||
({ height }) => {
|
||||
const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0
|
||||
setState("stickyHeaderHeight", titleHeight + height + 8)
|
||||
const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
|
||||
setStore("stickyHeaderHeight", titleHeight + height + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (props.stepsExpanded !== undefined) {
|
||||
setStore("stepsExpanded", props.stepsExpanded)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setStore("duration", duration())
|
||||
}, 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
let lastStatusChange = Date.now()
|
||||
let statusTimeout: number | undefined
|
||||
createEffect(() => {
|
||||
const newStatus = rawStatus()
|
||||
if (newStatus === store.status || !newStatus) return
|
||||
|
||||
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||
|
||||
if (timeSinceLastChange >= 2500) {
|
||||
setStore("status", newStatus)
|
||||
lastStatusChange = Date.now()
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = undefined
|
||||
}
|
||||
} else {
|
||||
if (statusTimeout) clearTimeout(statusTimeout)
|
||||
statusTimeout = setTimeout(() => {
|
||||
setStore("status", rawStatus())
|
||||
lastStatusChange = Date.now()
|
||||
statusTimeout = undefined
|
||||
}, 2500 - timeSinceLastChange) as unknown as number
|
||||
}
|
||||
})
|
||||
|
||||
createEffect((prev) => {
|
||||
const isWorking = working()
|
||||
if (!prev && isWorking) {
|
||||
setStore("stepsExpanded", true)
|
||||
props.onStepsExpandedChange?.(true)
|
||||
}
|
||||
if (prev && !isWorking && !store.userScrolled) {
|
||||
setStore("stepsExpanded", false)
|
||||
props.onStepsExpandedChange?.(false)
|
||||
}
|
||||
return isWorking
|
||||
}, working())
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
|
||||
<div onClick={handleInteraction}>
|
||||
<Show when={message()}>
|
||||
{(message) => {
|
||||
const assistantMessages = createMemo(() => {
|
||||
return messages()?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message().id,
|
||||
) as AssistantMessage[]
|
||||
})
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
|
||||
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const parts = createMemo(() => data.store.part[message().id])
|
||||
const lastTextPart = createMemo(() =>
|
||||
assistantMessageParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
?.at(-1),
|
||||
)
|
||||
const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
|
||||
const lastTextPartShown = createMemo(
|
||||
() => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
assistantParts().findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
p.tool === "task" &&
|
||||
p.state &&
|
||||
"metadata" in p.state &&
|
||||
p.state.metadata &&
|
||||
p.state.metadata.sessionId &&
|
||||
p.state.status === "running",
|
||||
) as ToolPart,
|
||||
)
|
||||
const resolvedParts = createMemo(() => {
|
||||
let resolved = assistantParts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
|
||||
(m) => m.role === "assistant",
|
||||
)
|
||||
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const last = lastPart()
|
||||
if (!last) return undefined
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
case "task":
|
||||
return "Delegating work"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning next steps"
|
||||
case "read":
|
||||
return "Gathering context"
|
||||
case "list":
|
||||
case "grep":
|
||||
case "glob":
|
||||
return "Searching the codebase"
|
||||
case "webfetch":
|
||||
return "Searching the web"
|
||||
case "edit":
|
||||
case "write":
|
||||
return "Making edits"
|
||||
case "bash":
|
||||
return "Running commands"
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
const text = last.text ?? ""
|
||||
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
|
||||
if (match) return `Thinking · ${match[1].trim()}`
|
||||
return "Thinking"
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts"
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function duration() {
|
||||
const completed = lastAssistantMessage()?.time.completed
|
||||
const from = DateTime.fromMillis(message()!.time.created)
|
||||
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
|
||||
const interval = Interval.fromDateTimes(from, to)
|
||||
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
|
||||
|
||||
return interval.toDuration(unit).normalize().toHuman({
|
||||
notation: "compact",
|
||||
unitDisplay: "narrow",
|
||||
compactDisplay: "short",
|
||||
showZeros: false,
|
||||
})
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
status: rawStatus(),
|
||||
stepsExpanded: props.stepsExpanded ?? working(),
|
||||
duration: duration(),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.stepsExpanded !== undefined) {
|
||||
setStore("stepsExpanded", props.stepsExpanded)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setStore("duration", duration())
|
||||
}, 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
let lastStatusChange = Date.now()
|
||||
let statusTimeout: number | undefined
|
||||
createEffect(() => {
|
||||
const newStatus = rawStatus()
|
||||
if (newStatus === store.status || !newStatus) return
|
||||
|
||||
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||
|
||||
if (timeSinceLastChange >= 2500) {
|
||||
setStore("status", newStatus)
|
||||
lastStatusChange = Date.now()
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = undefined
|
||||
}
|
||||
} else {
|
||||
if (statusTimeout) clearTimeout(statusTimeout)
|
||||
statusTimeout = setTimeout(() => {
|
||||
setStore("status", rawStatus())
|
||||
lastStatusChange = Date.now()
|
||||
statusTimeout = undefined
|
||||
}, 2500 - timeSinceLastChange) as unknown as number
|
||||
}
|
||||
})
|
||||
|
||||
createEffect((prev) => {
|
||||
const isWorking = working()
|
||||
if (!prev && isWorking) {
|
||||
setStore("stepsExpanded", true)
|
||||
props.onStepsExpandedChange?.(true)
|
||||
}
|
||||
if (prev && !isWorking && !state.userScrolled) {
|
||||
setStore("stepsExpanded", false)
|
||||
props.onStepsExpandedChange?.(false)
|
||||
}
|
||||
return isWorking
|
||||
}, working())
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => setState("contentRef", el)}
|
||||
data-message={message().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }}
|
||||
>
|
||||
{/* Title (sticky) */}
|
||||
<div ref={(el) => setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<h1>{message().summary?.title}</h1>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* User Message */}
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={message()} parts={parts()} />
|
||||
</div>
|
||||
{/* Trigger (sticky) */}
|
||||
<div ref={(el) => setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-expandable={assistantMessages().length > 0}
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (assistantMessages().length === 0) return
|
||||
const next = !store.stepsExpanded
|
||||
setStore("stepsExpanded", next)
|
||||
props.onStepsExpandedChange?.(next)
|
||||
}}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<div
|
||||
ref={(el) => setStore("contentRef", el)}
|
||||
data-message={message().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={isShellMode()}>
|
||||
<Part part={assistantParts()[0]} message={message()} defaultOpen />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{/* Title (sticky) */}
|
||||
<div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Switch>
|
||||
<Match when={retry()}>
|
||||
<span data-slot="session-turn-retry-message">
|
||||
{(() => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
|
||||
})()}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-seconds">
|
||||
· retrying {state.retrySeconds > 0 ? `in ${state.retrySeconds}s ` : ""}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
|
||||
<Match when={working()}>
|
||||
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<h1>{message().summary?.title ?? "New message"}</h1>
|
||||
</Match>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
|
||||
<Match when={store.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!store.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<Show when={store.stepsExpanded && assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
|
||||
const last = createMemo(() =>
|
||||
parts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
|
||||
<Message
|
||||
message={assistantMessage}
|
||||
parts={parts().filter((p) => p?.id !== last()?.id)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Message message={assistantMessage} parts={parts()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Summary */}
|
||||
<Show when={!working()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">
|
||||
</div>
|
||||
</div>
|
||||
{/* User Message */}
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={message()} parts={parts()} />
|
||||
</div>
|
||||
{/* Trigger (sticky) */}
|
||||
<div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-expandable={assistantMessages().length > 0}
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (assistantMessages().length === 0) return
|
||||
const next = !store.stepsExpanded
|
||||
setStore("stepsExpanded", next)
|
||||
props.onStepsExpandedChange?.(next)
|
||||
}}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={retry()}>
|
||||
<span data-slot="session-turn-retry-message">
|
||||
{(() => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
|
||||
})()}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-seconds">
|
||||
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
|
||||
</Match>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
|
||||
<Match when={store.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!store.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<Show when={store.stepsExpanded && assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
|
||||
const last = createMemo(() =>
|
||||
parts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={message().summary?.diffs?.length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
<Match when={response() && lastTextPart()?.id === last()?.id}>
|
||||
<Message message={assistantMessage} parts={parts().filter((p) => p?.id !== last()?.id)} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Message message={assistantMessage} parts={parts()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Summary */}
|
||||
<Show when={!working()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<Switch>
|
||||
<Match when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
data-slot="session-turn-markdown"
|
||||
data-diffs={!!message().summary?.diffs?.length}
|
||||
text={summary()}
|
||||
/>
|
||||
<>
|
||||
<h2 data-slot="session-turn-summary-title">Summary</h2>
|
||||
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={summary()} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion data-slot="session-turn-accordion" multiple>
|
||||
<For each={message().summary?.diffs ?? []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-accordion-trigger-content">
|
||||
<div data-slot="session-turn-file-info">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
data-slot="session-turn-file-icon"
|
||||
/>
|
||||
<div data-slot="session-turn-file-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-accordion-actions">
|
||||
<DiffChanges changes={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Match>
|
||||
<Match when={response()}>
|
||||
{(response) => (
|
||||
<>
|
||||
<h2 data-slot="session-turn-summary-title">Response</h2>
|
||||
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response()} />
|
||||
</>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Accordion data-slot="session-turn-accordion" multiple>
|
||||
<For each={message().summary?.diffs ?? []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-accordion-trigger-content">
|
||||
<div data-slot="session-turn-file-info">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
data-slot="session-turn-file-icon"
|
||||
/>
|
||||
<div data-slot="session-turn-file-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-turn-accordion-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
cacheKey: checksum(diff.before!),
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
cacheKey: checksum(diff.after!),
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !store.stepsExpanded}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<div data-slot="session-turn-accordion-actions">
|
||||
<DiffChanges changes={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-turn-accordion-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
cacheKey: checksum(diff.before!),
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
cacheKey: checksum(diff.after!),
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !store.stepsExpanded}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,99 @@
|
||||
import { randomBytes } from "crypto"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Identifier {
|
||||
const LENGTH = 26
|
||||
const prefixes = {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
} as const
|
||||
|
||||
export type Prefix = keyof typeof prefixes
|
||||
type CryptoLike = {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T
|
||||
}
|
||||
|
||||
const TOTAL_LENGTH = 26
|
||||
const RANDOM_LENGTH = TOTAL_LENGTH - 12
|
||||
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// State for monotonic ID generation
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
export function ascending() {
|
||||
return create(false)
|
||||
const fillRandomBytes = (buffer: Uint8Array) => {
|
||||
const cryptoLike = (globalThis as { crypto?: CryptoLike }).crypto
|
||||
if (cryptoLike?.getRandomValues) {
|
||||
cryptoLike.getRandomValues(buffer)
|
||||
return buffer
|
||||
}
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
buffer[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
export function descending() {
|
||||
return create(true)
|
||||
}
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const randomBase62 = (length: number) => {
|
||||
const bytes = fillRandomBytes(new Uint8Array(length))
|
||||
let result = ""
|
||||
const bytes = randomBytes(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i] % 62]
|
||||
result += BASE62[bytes[i] % BASE62.length]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function create(descending: boolean, timestamp?: number): string {
|
||||
const createSuffix = (descending: boolean, timestamp?: number) => {
|
||||
const currentTimestamp = timestamp ?? Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
counter += 1
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||
let value = BigInt(currentTimestamp) * 0x1000n + BigInt(counter)
|
||||
if (descending) value = ~value
|
||||
|
||||
now = descending ? ~now : now
|
||||
|
||||
const timeBytes = Buffer.alloc(6)
|
||||
const timeBytes = new Uint8Array(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
timeBytes[i] = Number((value >> BigInt(40 - 8 * i)) & 0xffn)
|
||||
}
|
||||
const hex = Array.from(timeBytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
return hex + randomBase62(RANDOM_LENGTH)
|
||||
}
|
||||
|
||||
return timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
const generateID = (prefix: Prefix, descending: boolean, given?: string, timestamp?: number) => {
|
||||
if (given) {
|
||||
const expected = `${prefixes[prefix]}_`
|
||||
if (!given.startsWith(expected)) throw new Error(`ID ${given} does not start with ${expected}`)
|
||||
return given
|
||||
}
|
||||
return `${prefixes[prefix]}_${createSuffix(descending, timestamp)}`
|
||||
}
|
||||
|
||||
export const schema = (prefix: Prefix) => z.string().startsWith(`${prefixes[prefix]}_`)
|
||||
|
||||
export function ascending(): string
|
||||
export function ascending(prefix: Prefix, given?: string): string
|
||||
export function ascending(prefix?: Prefix, given?: string) {
|
||||
if (prefix) return generateID(prefix, false, given)
|
||||
return create(false)
|
||||
}
|
||||
|
||||
export function descending(): string
|
||||
export function descending(prefix: Prefix, given?: string): string
|
||||
export function descending(prefix?: Prefix, given?: string) {
|
||||
if (prefix) return generateID(prefix, true, given)
|
||||
return create(true)
|
||||
}
|
||||
|
||||
export function create(descending: boolean, timestamp?: number) {
|
||||
return createSuffix(descending, timestamp)
|
||||
}
|
||||
|
||||
export function createPrefixed(prefix: Prefix, descending: boolean, timestamp?: number) {
|
||||
return generateID(prefix, descending, undefined, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user