chore: cleanup (#14113)
This commit is contained in:
@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
|
|||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
interface SessionContextTabProps {
|
|
||||||
messages: () => Message[]
|
|
||||||
visibleUserMessages: () => UserMessage[]
|
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
|
||||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||||
system: "var(--syntax-info)",
|
system: "var(--syntax-info)",
|
||||||
user: "var(--syntax-success)",
|
user: "var(--syntax-success)",
|
||||||
@@ -91,11 +85,45 @@ function RawMessage(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionContextTab(props: SessionContextTabProps) {
|
const emptyMessages: Message[] = []
|
||||||
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
|
export function SessionContextTab() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
|
||||||
|
const messages = createMemo(
|
||||||
|
() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return emptyMessages
|
||||||
|
return (sync.data.message[id] ?? []) as Message[]
|
||||||
|
},
|
||||||
|
emptyMessages,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
|
const userMessages = createMemo(
|
||||||
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
|
emptyUserMessages,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleUserMessages = createMemo(
|
||||||
|
() => {
|
||||||
|
const revert = info()?.revert?.messageID
|
||||||
|
if (!revert) return userMessages()
|
||||||
|
return userMessages().filter((m) => m.id < revert)
|
||||||
|
},
|
||||||
|
emptyUserMessages,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(language.locale(), {
|
new Intl.NumberFormat(language.locale(), {
|
||||||
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||||
|
|
||||||
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const counts = createMemo(() => {
|
const counts = createMemo(() => {
|
||||||
const all = props.messages()
|
const all = messages()
|
||||||
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||||
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||||
return {
|
return {
|
||||||
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const systemPrompt = createMemo(() => {
|
const systemPrompt = createMemo(() => {
|
||||||
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
|
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
|
||||||
const system = msg?.system
|
const system = msg?.system
|
||||||
if (!system) return
|
if (!system) return
|
||||||
const trimmed = system.trim()
|
const trimmed = system.trim()
|
||||||
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
|
|
||||||
const breakdown = createMemo(
|
const breakdown = createMemo(
|
||||||
on(
|
on(
|
||||||
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
||||||
() => {
|
() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c?.input) return []
|
if (!c?.input) return []
|
||||||
return estimateSessionContextBreakdown({
|
return estimateSessionContextBreakdown({
|
||||||
messages: props.messages(),
|
messages: messages(),
|
||||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||||
input: c.input,
|
input: c.input,
|
||||||
systemPrompt: systemPrompt(),
|
systemPrompt: systemPrompt(),
|
||||||
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
|
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
||||||
{ label: "context.stats.totalCost", value: cost },
|
{ label: "context.stats.totalCost", value: cost },
|
||||||
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
|
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
] satisfies { label: string; value: () => JSX.Element }[]
|
] satisfies { label: string; value: () => JSX.Element }[]
|
||||||
|
|
||||||
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
const s = props.view()?.scroll("context")
|
const s = view().scroll("context")
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||||
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
pending = undefined
|
pending = undefined
|
||||||
if (!next) return
|
if (!next) return
|
||||||
|
|
||||||
props.view().setScroll("context", next)
|
view().setScroll("context", next)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.messages().length,
|
() => messages().length,
|
||||||
() => {
|
() => {
|
||||||
requestAnimationFrame(restoreScroll)
|
requestAnimationFrame(restoreScroll)
|
||||||
},
|
},
|
||||||
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={props.messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { Dynamic } from "solid-js/web"
|
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
|
||||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
|
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
@@ -34,16 +28,14 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
|
import { SessionHeader, NewSessionView } from "@/components/session"
|
||||||
import { navMark, navParams } from "@/utils/perf"
|
import { navMark, navParams } from "@/utils/perf"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
|
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
|
||||||
import {
|
import {
|
||||||
SessionReviewTab,
|
SessionReviewTab,
|
||||||
StickyAddButton,
|
StickyAddButton,
|
||||||
@@ -51,7 +43,6 @@ import {
|
|||||||
type SessionReviewTabProps,
|
type SessionReviewTabProps,
|
||||||
} from "@/pages/session/review-tab"
|
} from "@/pages/session/review-tab"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
|
||||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
|
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
|
||||||
@@ -59,42 +50,13 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
|||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
|
|
||||||
type HandoffSession = {
|
|
||||||
prompt: string
|
|
||||||
files: Record<string, SelectedLineRange | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
const HANDOFF_MAX = 40
|
|
||||||
|
|
||||||
const handoff = {
|
|
||||||
session: new Map<string, HandoffSession>(),
|
|
||||||
terminal: new Map<string, string[]>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
|
||||||
map.delete(key)
|
|
||||||
map.set(key, value)
|
|
||||||
while (map.size > HANDOFF_MAX) {
|
|
||||||
const first = map.keys().next().value
|
|
||||||
if (first === undefined) return
|
|
||||||
map.delete(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
|
||||||
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
|
|
||||||
touch(handoff.session, key, { ...prev, ...patch })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const codeComponent = useCodeComponent()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -104,53 +66,21 @@ export default function Page() {
|
|||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
|
||||||
const permRequest = createMemo(() => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
return sync.data.permission[sessionID]?.[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const questionRequest = createMemo(() => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
return sync.data.question[sessionID]?.[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
|
|
||||||
|
|
||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
responding: false,
|
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
autoCreated: false,
|
|
||||||
scroll: {
|
scroll: {
|
||||||
overflow: false,
|
overflow: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
const blocked = createMemo(() => {
|
||||||
on(
|
const sessionID = params.id
|
||||||
() => permRequest()?.id,
|
if (!sessionID) return false
|
||||||
() => setUi("responding", false),
|
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
|
||||||
{ defer: true },
|
})
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const decide = (response: "once" | "always" | "reject") => {
|
|
||||||
const perm = permRequest()
|
|
||||||
if (!perm) return
|
|
||||||
if (ui.responding) return
|
|
||||||
|
|
||||||
setUi("responding", true)
|
|
||||||
sdk.client.permission
|
|
||||||
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
.finally(() => setUi("responding", false))
|
|
||||||
}
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const workspaceKey = createMemo(() => params.dir ?? "")
|
const workspaceKey = createMemo(() => params.dir ?? "")
|
||||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||||
@@ -323,206 +253,6 @@ export default function Page() {
|
|||||||
return sync.session.history.loading(id)
|
return sync.session.history.loading(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [title, setTitle] = createStore({
|
|
||||||
draft: "",
|
|
||||||
editing: false,
|
|
||||||
saving: false,
|
|
||||||
menuOpen: false,
|
|
||||||
pendingRename: false,
|
|
||||||
})
|
|
||||||
let titleRef: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
|
||||||
const data = (err as { data?: { message?: string } }).data
|
|
||||||
if (data?.message) return data.message
|
|
||||||
}
|
|
||||||
if (err instanceof Error) return err.message
|
|
||||||
return language.t("common.requestFailed")
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
sessionKey,
|
|
||||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const openTitleEditor = () => {
|
|
||||||
if (!params.id) return
|
|
||||||
setTitle({ editing: true, draft: info()?.title ?? "" })
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
titleRef?.focus()
|
|
||||||
titleRef?.select()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeTitleEditor = () => {
|
|
||||||
if (title.saving) return
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTitleEditor = async () => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
if (title.saving) return
|
|
||||||
|
|
||||||
const next = title.draft.trim()
|
|
||||||
if (!next || next === (info()?.title ?? "")) {
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTitle("saving", true)
|
|
||||||
await sdk.client.session
|
|
||||||
.update({ sessionID, title: next })
|
|
||||||
.then(() => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
|
||||||
if (index !== -1) draft.session[index].title = next
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setTitle("saving", false)
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
|
||||||
if (params.id !== sessionID) return
|
|
||||||
if (parentID) {
|
|
||||||
navigate(`/${params.dir}/session/${parentID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextSessionID) {
|
|
||||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate(`/${params.dir}/session`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveSession(sessionID: string) {
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const sessions = sync.data.session ?? []
|
|
||||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
|
||||||
|
|
||||||
await sdk.client.session
|
|
||||||
.update({ sessionID, time: { archived: Date.now() } })
|
|
||||||
.then(() => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
|
||||||
if (index !== -1) draft.session.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSession(sessionID: string) {
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (!session) return false
|
|
||||||
|
|
||||||
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
|
||||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
|
||||||
|
|
||||||
const result = await sdk.client.session
|
|
||||||
.delete({ sessionID })
|
|
||||||
.then((x) => x.data)
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("session.delete.failed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) return false
|
|
||||||
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const removed = new Set<string>([sessionID])
|
|
||||||
|
|
||||||
const byParent = new Map<string, string[]>()
|
|
||||||
for (const item of draft.session) {
|
|
||||||
const parentID = item.parentID
|
|
||||||
if (!parentID) continue
|
|
||||||
const existing = byParent.get(parentID)
|
|
||||||
if (existing) {
|
|
||||||
existing.push(item.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
byParent.set(parentID, [item.id])
|
|
||||||
}
|
|
||||||
|
|
||||||
const stack = [sessionID]
|
|
||||||
while (stack.length) {
|
|
||||||
const parentID = stack.pop()
|
|
||||||
if (!parentID) continue
|
|
||||||
|
|
||||||
const children = byParent.get(parentID)
|
|
||||||
if (!children) continue
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
if (removed.has(child)) continue
|
|
||||||
removed.add(child)
|
|
||||||
stack.push(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDeleteSession(props: { sessionID: string }) {
|
|
||||||
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
|
||||||
const handleDelete = async () => {
|
|
||||||
await deleteSession(props.sessionID)
|
|
||||||
dialog.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={language.t("session.delete.title")} fit>
|
|
||||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-14-regular text-text-strong">
|
|
||||||
{language.t("session.delete.confirm", { name: title() })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
|
||||||
{language.t("session.delete.button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
@@ -555,8 +285,6 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
activeDraggable: undefined as string | undefined,
|
|
||||||
activeTerminalDraggable: undefined as string | undefined,
|
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
turnStart: 0,
|
turnStart: 0,
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
@@ -679,43 +407,6 @@ export default function Page() {
|
|||||||
void sync.session.todo(id)
|
void sync.session.todo(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!view().terminal.opened()) {
|
|
||||||
setUi("autoCreated", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
|
|
||||||
terminal.new()
|
|
||||||
setUi("autoCreated", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => terminal.all().length,
|
|
||||||
(count, prevCount) => {
|
|
||||||
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
|
||||||
if (view().terminal.opened()) {
|
|
||||||
view().terminal.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => terminal.active(),
|
|
||||||
(activeId) => {
|
|
||||||
if (!activeId || !view().terminal.opened()) return
|
|
||||||
// Immediately remove focus
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur()
|
|
||||||
}
|
|
||||||
focusTerminalById(activeId)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => visibleUserMessages().at(-1)?.id,
|
() => visibleUserMessages().at(-1)?.id,
|
||||||
@@ -729,11 +420,6 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||||
const todos = createMemo(() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return []
|
|
||||||
return globalSync.data.session_todo[id] ?? []
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -741,7 +427,6 @@ export default function Page() {
|
|||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("changes", "session")
|
setStore("changes", "session")
|
||||||
setUi("autoCreated", false)
|
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
@@ -827,53 +512,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
setStore("activeDraggable", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
|
||||||
const { draggable, droppable } = event
|
|
||||||
if (draggable && droppable) {
|
|
||||||
const currentTabs = tabs().all()
|
|
||||||
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
|
|
||||||
if (toIndex === undefined) return
|
|
||||||
tabs().move(draggable.id.toString(), toIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setStore("activeDraggable", undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTerminalDragStart = (event: unknown) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
setStore("activeTerminalDraggable", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTerminalDragOver = (event: DragEvent) => {
|
|
||||||
const { draggable, droppable } = event
|
|
||||||
if (draggable && droppable) {
|
|
||||||
const terminals = terminal.all()
|
|
||||||
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
|
||||||
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
|
||||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
||||||
terminal.move(draggable.id.toString(), toIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTerminalDragEnd = () => {
|
|
||||||
setStore("activeTerminalDraggable", undefined)
|
|
||||||
const activeId = terminal.active()
|
|
||||||
if (!activeId) return
|
|
||||||
setTimeout(() => {
|
|
||||||
focusTerminalById(activeId)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
||||||
const openedTabs = createMemo(() =>
|
const openedTabs = createMemo(() =>
|
||||||
tabs()
|
tabs()
|
||||||
@@ -1485,58 +1123,6 @@ export default function Page() {
|
|||||||
document.addEventListener("keydown", handleKeyDown)
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewPrompt = () =>
|
|
||||||
prompt
|
|
||||||
.current()
|
|
||||||
.map((part) => {
|
|
||||||
if (part.type === "file") return `[file:${part.path}]`
|
|
||||||
if (part.type === "agent") return `@${part.name}`
|
|
||||||
if (part.type === "image") return `[image:${part.filename}]`
|
|
||||||
return part.content
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!prompt.ready()) return
|
|
||||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!terminal.ready()) return
|
|
||||||
language.locale()
|
|
||||||
|
|
||||||
touch(
|
|
||||||
handoff.terminal,
|
|
||||||
params.dir!,
|
|
||||||
terminal.all().map((pty) =>
|
|
||||||
terminalTabLabel({
|
|
||||||
title: pty.title,
|
|
||||||
titleNumber: pty.titleNumber,
|
|
||||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!file.ready()) return
|
|
||||||
setSessionHandoff(sessionKey(), {
|
|
||||||
files: tabs()
|
|
||||||
.all()
|
|
||||||
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
|
|
||||||
const path = file.pathFromTab(tab)
|
|
||||||
if (!path) return acc
|
|
||||||
const selected = file.selectedLines(path)
|
|
||||||
acc[path] =
|
|
||||||
selected && typeof selected === "object" && "start" in selected && "end" in selected
|
|
||||||
? (selected as SelectedLineRange)
|
|
||||||
: null
|
|
||||||
return acc
|
|
||||||
}, {}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelTurnBackfill()
|
cancelTurnBackfill()
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
@@ -1555,7 +1141,6 @@ export default function Page() {
|
|||||||
reviewCount={reviewCount()}
|
reviewCount={reviewCount()}
|
||||||
onSession={() => setStore("mobileTab", "session")}
|
onSession={() => setStore("mobileTab", "session")}
|
||||||
onChanges={() => setStore("mobileTab", "changes")}
|
onChanges={() => setStore("mobileTab", "changes")}
|
||||||
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Session panel */}
|
{/* Session panel */}
|
||||||
@@ -1595,27 +1180,7 @@ export default function Page() {
|
|||||||
isDesktop={isDesktop()}
|
isDesktop={isDesktop()}
|
||||||
onScrollSpyScroll={scrollSpy.onScroll}
|
onScrollSpyScroll={scrollSpy.onScroll}
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
showHeader={!!(info()?.title || info()?.parentID)}
|
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
title={info()?.title}
|
|
||||||
parentID={info()?.parentID}
|
|
||||||
openTitleEditor={openTitleEditor}
|
|
||||||
closeTitleEditor={closeTitleEditor}
|
|
||||||
saveTitleEditor={saveTitleEditor}
|
|
||||||
titleRef={(el) => {
|
|
||||||
titleRef = el
|
|
||||||
}}
|
|
||||||
titleState={title}
|
|
||||||
onTitleDraft={(value) => setTitle("draft", value)}
|
|
||||||
onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
|
|
||||||
onTitlePendingRename={(value) => setTitle("pendingRename", value)}
|
|
||||||
onNavigateParent={() => {
|
|
||||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
|
||||||
}}
|
|
||||||
sessionID={params.id!}
|
|
||||||
onArchiveSession={(sessionID) => void archiveSession(sessionID)}
|
|
||||||
onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
|
|
||||||
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
|
|
||||||
setContentRef={(el) => {
|
setContentRef={(el) => {
|
||||||
content = el
|
content = el
|
||||||
autoScroll.contentRef(el)
|
autoScroll.contentRef(el)
|
||||||
@@ -1670,15 +1235,6 @@ export default function Page() {
|
|||||||
|
|
||||||
<SessionPromptDock
|
<SessionPromptDock
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
questionRequest={questionRequest}
|
|
||||||
permissionRequest={permRequest}
|
|
||||||
blocked={blocked()}
|
|
||||||
todos={todos()}
|
|
||||||
promptReady={prompt.ready()}
|
|
||||||
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
|
|
||||||
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
|
|
||||||
responding={ui.responding}
|
|
||||||
onDecide={decide}
|
|
||||||
inputRef={(el) => {
|
inputRef={(el) => {
|
||||||
inputRef = el
|
inputRef = el
|
||||||
}}
|
}}
|
||||||
@@ -1688,7 +1244,9 @@ export default function Page() {
|
|||||||
comments.clear()
|
comments.clear()
|
||||||
resumeScroll()
|
resumeScroll()
|
||||||
}}
|
}}
|
||||||
setPromptDockRef={(el) => (promptDock = el)}
|
setPromptDockRef={(el) => {
|
||||||
|
promptDock = el
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
@@ -1702,64 +1260,10 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel
|
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
|
||||||
open={desktopSidePanelOpen()}
|
|
||||||
reviewOpen={desktopReviewOpen()}
|
|
||||||
language={language}
|
|
||||||
layout={layout}
|
|
||||||
command={command}
|
|
||||||
dialog={dialog}
|
|
||||||
file={file}
|
|
||||||
comments={comments}
|
|
||||||
hasReview={hasReview()}
|
|
||||||
reviewCount={reviewCount()}
|
|
||||||
reviewTab={reviewTab()}
|
|
||||||
contextOpen={contextOpen}
|
|
||||||
openedTabs={openedTabs}
|
|
||||||
activeTab={activeTab}
|
|
||||||
activeFileTab={activeFileTab}
|
|
||||||
tabs={tabs}
|
|
||||||
openTab={openTab}
|
|
||||||
showAllFiles={showAllFiles}
|
|
||||||
reviewPanel={reviewPanel}
|
|
||||||
vm={{
|
|
||||||
messages,
|
|
||||||
visibleUserMessages,
|
|
||||||
view,
|
|
||||||
info,
|
|
||||||
}}
|
|
||||||
handoffFiles={() => handoff.session.get(sessionKey())?.files}
|
|
||||||
codeComponent={codeComponent}
|
|
||||||
addCommentToContext={addCommentToContext}
|
|
||||||
activeDraggable={() => store.activeDraggable}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
fileTreeTab={fileTreeTab}
|
|
||||||
setFileTreeTabValue={setFileTreeTabValue}
|
|
||||||
diffsReady={diffsReady()}
|
|
||||||
diffFiles={diffFiles()}
|
|
||||||
kinds={kinds()}
|
|
||||||
activeDiff={tree.activeDiff}
|
|
||||||
focusReviewDiff={focusReviewDiff}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TerminalPanel
|
<TerminalPanel />
|
||||||
open={isDesktop() && view().terminal.opened()}
|
|
||||||
height={layout.terminal.height()}
|
|
||||||
resize={layout.terminal.resize}
|
|
||||||
close={view().terminal.close}
|
|
||||||
terminal={terminal}
|
|
||||||
language={language}
|
|
||||||
command={command}
|
|
||||||
handoff={() => handoff.terminal.get(params.dir!) ?? []}
|
|
||||||
activeTerminalDraggable={() => store.activeTerminalDraggable}
|
|
||||||
handleTerminalDragStart={handleTerminalDragStart}
|
|
||||||
handleTerminalDragOver={handleTerminalDragOver}
|
|
||||||
handleTerminalDragEnd={handleTerminalDragEnd}
|
|
||||||
onCloseTab={() => setUi("autoCreated", false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
@@ -8,9 +10,11 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/
|
|||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||||
|
|
||||||
const formatCommentLabel = (range: SelectedLineRange) => {
|
const formatCommentLabel = (range: SelectedLineRange) => {
|
||||||
const start = Math.min(range.start, range.end)
|
const start = Math.min(range.start, range.end)
|
||||||
@@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => {
|
|||||||
return `lines ${start}-${end}`
|
return `lines ${start}-${end}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileTabContent(props: {
|
export function FileTabContent(props: { tab: string }) {
|
||||||
tab: string
|
const params = useParams()
|
||||||
activeTab: () => string
|
const layout = useLayout()
|
||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
const file = useFile()
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
const comments = useComments()
|
||||||
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
const language = useLanguage()
|
||||||
file: ReturnType<typeof useFile>
|
const prompt = usePrompt()
|
||||||
comments: ReturnType<typeof useComments>
|
const codeComponent = useCodeComponent()
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
codeComponent: NonNullable<ValidComponent>
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
addCommentToContext: (input: {
|
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||||
file: string
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
selection: SelectedLineRange
|
|
||||||
comment: string
|
|
||||||
preview?: string
|
|
||||||
origin?: "review" | "file"
|
|
||||||
}) => void
|
|
||||||
}) {
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let scrollFrame: number | undefined
|
let scrollFrame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
let codeScroll: HTMLElement[] = []
|
let codeScroll: HTMLElement[] = []
|
||||||
|
|
||||||
const path = createMemo(() => props.file.pathFromTab(props.tab))
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
const state = createMemo(() => {
|
const state = createMemo(() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
return props.file.get(p)
|
return file.get(p)
|
||||||
})
|
})
|
||||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||||
@@ -82,7 +81,7 @@ export function FileTabContent(props: {
|
|||||||
svgToast.shown = true
|
svgToast.shown = true
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: props.language.t("toast.file.loadFailed.title"),
|
title: language.t("toast.file.loadFailed.title"),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const svgPreviewUrl = createMemo(() => {
|
const svgPreviewUrl = createMemo(() => {
|
||||||
@@ -100,16 +99,57 @@ export function FileTabContent(props: {
|
|||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
if (props.file.ready()) return props.file.selectedLines(p) ?? null
|
if (file.ready()) return file.selectedLines(p) ?? null
|
||||||
return props.handoffFiles()?.[p] ?? null
|
return getSessionHandoff(sessionKey())?.files[p] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||||
|
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
||||||
|
const end = Math.max(selection.startLine, selection.endLine)
|
||||||
|
const lines = source.split("\n").slice(start - 1, end)
|
||||||
|
if (lines.length === 0) return undefined
|
||||||
|
return lines.slice(0, 2).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCommentToContext = (input: {
|
||||||
|
file: string
|
||||||
|
selection: SelectedLineRange
|
||||||
|
comment: string
|
||||||
|
preview?: 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 saved = comments.add({
|
||||||
|
file: input.file,
|
||||||
|
selection: input.selection,
|
||||||
|
comment: input.comment,
|
||||||
|
})
|
||||||
|
prompt.context.add({
|
||||||
|
type: "file",
|
||||||
|
path: input.file,
|
||||||
|
selection,
|
||||||
|
comment: input.comment,
|
||||||
|
commentID: saved.id,
|
||||||
|
commentOrigin: input.origin,
|
||||||
|
preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let wrap: HTMLDivElement | undefined
|
let wrap: HTMLDivElement | undefined
|
||||||
|
|
||||||
const fileComments = createMemo(() => {
|
const fileComments = createMemo(() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return []
|
if (!p) return []
|
||||||
return props.comments.list(p)
|
return comments.list(p)
|
||||||
})
|
})
|
||||||
|
|
||||||
const commentLayout = createMemo(() => {
|
const commentLayout = createMemo(() => {
|
||||||
@@ -228,19 +268,19 @@ export function FileTabContent(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const focus = props.comments.focus()
|
const focus = comments.focus()
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!focus || !p) return
|
if (!focus || !p) return
|
||||||
if (focus.file !== p) return
|
if (focus.file !== p) return
|
||||||
if (props.activeTab() !== props.tab) return
|
if (tabs().active() !== props.tab) return
|
||||||
|
|
||||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
setNote("openedComment", target.id)
|
setNote("openedComment", target.id)
|
||||||
setNote("commenting", null)
|
setNote("commenting", null)
|
||||||
props.file.setSelectedLines(p, target.selection)
|
file.setSelectedLines(p, target.selection)
|
||||||
requestAnimationFrame(() => props.comments.clearFocus())
|
requestAnimationFrame(() => comments.clearFocus())
|
||||||
})
|
})
|
||||||
|
|
||||||
const getCodeScroll = () => {
|
const getCodeScroll = () => {
|
||||||
@@ -269,7 +309,7 @@ export function FileTabContent(props: {
|
|||||||
pending = undefined
|
pending = undefined
|
||||||
if (!out) return
|
if (!out) return
|
||||||
|
|
||||||
props.view().setScroll(props.tab, out)
|
view().setScroll(props.tab, out)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +345,7 @@ export function FileTabContent(props: {
|
|||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
const s = props.view()?.scroll(props.tab)
|
const s = view().scroll(props.tab)
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
syncCodeScroll()
|
syncCodeScroll()
|
||||||
@@ -343,7 +383,7 @@ export function FileTabContent(props: {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.file.ready(),
|
() => file.ready(),
|
||||||
(ready) => {
|
(ready) => {
|
||||||
if (!ready) return
|
if (!ready) return
|
||||||
requestAnimationFrame(restoreScroll)
|
requestAnimationFrame(restoreScroll)
|
||||||
@@ -354,7 +394,7 @@ export function FileTabContent(props: {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.tabs().active() === props.tab,
|
() => tabs().active() === props.tab,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
if (!state()?.loaded) return
|
if (!state()?.loaded) return
|
||||||
@@ -381,7 +421,7 @@ export function FileTabContent(props: {
|
|||||||
class={`relative overflow-hidden ${wrapperClass}`}
|
class={`relative overflow-hidden ${wrapperClass}`}
|
||||||
>
|
>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.codeComponent}
|
component={codeComponent}
|
||||||
file={{
|
file={{
|
||||||
name: path() ?? "",
|
name: path() ?? "",
|
||||||
contents: source,
|
contents: source,
|
||||||
@@ -397,7 +437,7 @@ export function FileTabContent(props: {
|
|||||||
onLineSelected={(range: SelectedLineRange | null) => {
|
onLineSelected={(range: SelectedLineRange | null) => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
props.file.setSelectedLines(p, range)
|
file.setSelectedLines(p, range)
|
||||||
if (!range) setNote("commenting", null)
|
if (!range) setNote("commenting", null)
|
||||||
}}
|
}}
|
||||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||||
@@ -423,14 +463,14 @@ export function FileTabContent(props: {
|
|||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
props.file.setSelectedLines(p, comment.selection)
|
file.setSelectedLines(p, comment.selection)
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
setNote("commenting", null)
|
setNote("commenting", null)
|
||||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||||
props.file.setSelectedLines(p, comment.selection)
|
file.setSelectedLines(p, comment.selection)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -447,12 +487,7 @@ export function FileTabContent(props: {
|
|||||||
onSubmit={(value) => {
|
onSubmit={(value) => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
props.addCommentToContext({
|
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
|
||||||
file: p,
|
|
||||||
selection: range(),
|
|
||||||
comment: value,
|
|
||||||
origin: "file",
|
|
||||||
})
|
|
||||||
setNote("commenting", null)
|
setNote("commenting", null)
|
||||||
}}
|
}}
|
||||||
onPopoverFocusOut={(e: FocusEvent) => {
|
onPopoverFocusOut={(e: FocusEvent) => {
|
||||||
@@ -509,13 +544,13 @@ export function FileTabContent(props: {
|
|||||||
<Mark class="w-14 opacity-10" />
|
<Mark class="w-14 opacity-10" />
|
||||||
<div class="flex flex-col gap-2 max-w-md">
|
<div class="flex flex-col gap-2 max-w-md">
|
||||||
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
||||||
<div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
|
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
||||||
<Match when={state()?.loading}>
|
<Match when={state()?.loading}>
|
||||||
<div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
|
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
36
packages/app/src/pages/session/handoff.ts
Normal file
36
packages/app/src/pages/session/handoff.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { SelectedLineRange } from "@/context/file"
|
||||||
|
|
||||||
|
type HandoffSession = {
|
||||||
|
prompt: string
|
||||||
|
files: Record<string, SelectedLineRange | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX = 40
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
session: new Map<string, HandoffSession>(),
|
||||||
|
terminal: new Map<string, string[]>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||||
|
map.delete(key)
|
||||||
|
map.set(key, value)
|
||||||
|
while (map.size > MAX) {
|
||||||
|
const first = map.keys().next().value
|
||||||
|
if (first === undefined) return
|
||||||
|
map.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
||||||
|
const prev = store.session.get(key) ?? { prompt: "", files: {} }
|
||||||
|
touch(store.session, key, { ...prev, ...patch })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionHandoff = (key: string) => store.session.get(key)
|
||||||
|
|
||||||
|
export const setTerminalHandoff = (key: string, value: string[]) => {
|
||||||
|
touch(store.terminal, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTerminalHandoff = (key: string) => store.terminal.get(key)
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
|
||||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||||
const current = target instanceof Element ? target : undefined
|
const current = target instanceof Element ? target : undefined
|
||||||
@@ -53,29 +61,7 @@ export function MessageTimeline(props: {
|
|||||||
isDesktop: boolean
|
isDesktop: boolean
|
||||||
onScrollSpyScroll: () => void
|
onScrollSpyScroll: () => void
|
||||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||||
showHeader: boolean
|
|
||||||
centered: boolean
|
centered: boolean
|
||||||
title?: string
|
|
||||||
parentID?: string
|
|
||||||
openTitleEditor: () => void
|
|
||||||
closeTitleEditor: () => void
|
|
||||||
saveTitleEditor: () => void | Promise<void>
|
|
||||||
titleRef: (el: HTMLInputElement) => void
|
|
||||||
titleState: {
|
|
||||||
draft: string
|
|
||||||
editing: boolean
|
|
||||||
saving: boolean
|
|
||||||
menuOpen: boolean
|
|
||||||
pendingRename: boolean
|
|
||||||
}
|
|
||||||
onTitleDraft: (value: string) => void
|
|
||||||
onTitleMenuOpen: (open: boolean) => void
|
|
||||||
onTitlePendingRename: (value: boolean) => void
|
|
||||||
onNavigateParent: () => void
|
|
||||||
sessionID: string
|
|
||||||
onArchiveSession: (sessionID: string) => void
|
|
||||||
onDeleteSession: (sessionID: string) => void
|
|
||||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
|
||||||
setContentRef: (el: HTMLDivElement) => void
|
setContentRef: (el: HTMLDivElement) => void
|
||||||
turnStart: number
|
turnStart: number
|
||||||
onRenderEarlier: () => void
|
onRenderEarlier: () => void
|
||||||
@@ -91,6 +77,230 @@ export function MessageTimeline(props: {
|
|||||||
}) {
|
}) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
|
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const sessionID = createMemo(() => params.id)
|
||||||
|
const info = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return
|
||||||
|
return sync.session.get(id)
|
||||||
|
})
|
||||||
|
const titleValue = createMemo(() => info()?.title)
|
||||||
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
|
|
||||||
|
const [title, setTitle] = createStore({
|
||||||
|
draft: "",
|
||||||
|
editing: false,
|
||||||
|
saving: false,
|
||||||
|
menuOpen: false,
|
||||||
|
pendingRename: false,
|
||||||
|
})
|
||||||
|
let titleRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
const errorMessage = (err: unknown) => {
|
||||||
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
|
const data = (err as { data?: { message?: string } }).data
|
||||||
|
if (data?.message) return data.message
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message
|
||||||
|
return language.t("common.requestFailed")
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
sessionKey,
|
||||||
|
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const openTitleEditor = () => {
|
||||||
|
if (!sessionID()) return
|
||||||
|
setTitle({ editing: true, draft: titleValue() ?? "" })
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
titleRef?.focus()
|
||||||
|
titleRef?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTitleEditor = () => {
|
||||||
|
if (title.saving) return
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTitleEditor = async () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return
|
||||||
|
if (title.saving) return
|
||||||
|
|
||||||
|
const next = title.draft.trim()
|
||||||
|
if (!next || next === (titleValue() ?? "")) {
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle("saving", true)
|
||||||
|
await sdk.client.session
|
||||||
|
.update({ sessionID: id, title: next })
|
||||||
|
.then(() => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === id)
|
||||||
|
if (index !== -1) draft.session[index].title = next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setTitle("saving", false)
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
|
if (params.id !== sessionID) return
|
||||||
|
if (parentID) {
|
||||||
|
navigate(`/${params.dir}/session/${parentID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextSessionID) {
|
||||||
|
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(`/${params.dir}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveSession = async (sessionID: string) => {
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const sessions = sync.data.session ?? []
|
||||||
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||||
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
|
await sdk.client.session
|
||||||
|
.update({ sessionID, time: { archived: Date.now() } })
|
||||||
|
.then(() => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||||
|
if (index !== -1) draft.session.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSession = async (sessionID: string) => {
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (!session) return false
|
||||||
|
|
||||||
|
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||||
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||||
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
|
const result = await sdk.client.session
|
||||||
|
.delete({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("session.delete.failed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) return false
|
||||||
|
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const removed = new Set<string>([sessionID])
|
||||||
|
|
||||||
|
const byParent = new Map<string, string[]>()
|
||||||
|
for (const item of draft.session) {
|
||||||
|
const parentID = item.parentID
|
||||||
|
if (!parentID) continue
|
||||||
|
const existing = byParent.get(parentID)
|
||||||
|
if (existing) {
|
||||||
|
existing.push(item.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byParent.set(parentID, [item.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [sessionID]
|
||||||
|
while (stack.length) {
|
||||||
|
const parentID = stack.pop()
|
||||||
|
if (!parentID) continue
|
||||||
|
|
||||||
|
const children = byParent.get(parentID)
|
||||||
|
if (!children) continue
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (removed.has(child)) continue
|
||||||
|
removed.add(child)
|
||||||
|
stack.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateParent = () => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return
|
||||||
|
navigate(`/${params.dir}/session/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
|
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await deleteSession(props.sessionID)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={language.t("session.delete.title")} fit>
|
||||||
|
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-14-regular text-text-strong">
|
||||||
|
{language.t("session.delete.confirm", { name: name() })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||||
|
{language.t("session.delete.button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={!props.mobileChanges}
|
when={!props.mobileChanges}
|
||||||
@@ -157,9 +367,9 @@ export function MessageTimeline(props: {
|
|||||||
}}
|
}}
|
||||||
onClick={props.onAutoScrollInteraction}
|
onClick={props.onAutoScrollInteraction}
|
||||||
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
||||||
style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
|
style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
|
||||||
>
|
>
|
||||||
<Show when={props.showHeader}>
|
<Show when={showHeader()}>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||||
@@ -171,92 +381,96 @@ export function MessageTimeline(props: {
|
|||||||
>
|
>
|
||||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||||
<Show when={props.parentID}>
|
<Show when={parentID()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
icon="arrow-left"
|
icon="arrow-left"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={props.onNavigateParent}
|
onClick={navigateParent}
|
||||||
aria-label={props.t("common.goBack")}
|
aria-label={language.t("common.goBack")}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.title || props.titleState.editing}>
|
<Show when={titleValue() || title.editing}>
|
||||||
<Show
|
<Show
|
||||||
when={props.titleState.editing}
|
when={title.editing}
|
||||||
fallback={
|
fallback={
|
||||||
<h1
|
<h1
|
||||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||||
onDblClick={props.openTitleEditor}
|
onDblClick={openTitleEditor}
|
||||||
>
|
>
|
||||||
{props.title}
|
{titleValue()}
|
||||||
</h1>
|
</h1>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InlineInput
|
<InlineInput
|
||||||
ref={props.titleRef}
|
ref={(el) => {
|
||||||
value={props.titleState.draft}
|
titleRef = el
|
||||||
disabled={props.titleState.saving}
|
}}
|
||||||
|
value={title.draft}
|
||||||
|
disabled={title.saving}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||||
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void props.saveTitleEditor()
|
void saveTitleEditor()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.closeTitleEditor()
|
closeTitleEditor()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={props.closeTitleEditor}
|
onBlur={closeTitleEditor}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.sessionID}>
|
<Show when={sessionID()}>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
<div class="shrink-0 flex items-center gap-3">
|
||||||
<SessionContextUsage placement="bottom" />
|
<SessionContextUsage placement="bottom" />
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
gutter={4}
|
gutter={4}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
open={props.titleState.menuOpen}
|
open={title.menuOpen}
|
||||||
onOpenChange={props.onTitleMenuOpen}
|
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
icon="dot-grid"
|
icon="dot-grid"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
aria-label={props.t("common.moreOptions")}
|
aria-label={language.t("common.moreOptions")}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
style={{ "min-width": "104px" }}
|
style={{ "min-width": "104px" }}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
if (!props.titleState.pendingRename) return
|
if (!title.pendingRename) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.onTitlePendingRename(false)
|
setTitle("pendingRename", false)
|
||||||
props.openTitleEditor()
|
openTitleEditor()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
props.onTitlePendingRename(true)
|
setTitle("pendingRename", true)
|
||||||
props.onTitleMenuOpen(false)
|
setTitle("menuOpen", false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||||
<DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
@@ -282,7 +496,7 @@ export function MessageTimeline(props: {
|
|||||||
<Show when={props.turnStart > 0}>
|
<Show when={props.turnStart > 0}>
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
|
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
|
||||||
{props.t("session.messages.renderEarlier")}
|
{language.t("session.messages.renderEarlier")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -296,8 +510,8 @@ export function MessageTimeline(props: {
|
|||||||
onClick={props.onLoadEarlier}
|
onClick={props.onLoadEarlier}
|
||||||
>
|
>
|
||||||
{props.historyLoading
|
{props.historyLoading
|
||||||
? props.t("session.messages.loadingEarlier")
|
? language.t("session.messages.loadingEarlier")
|
||||||
: props.t("session.messages.loadEarlier")}
|
: language.t("session.messages.loadEarlier")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -321,7 +535,7 @@ export function MessageTimeline(props: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionTurn
|
<SessionTurn
|
||||||
sessionID={props.sessionID}
|
sessionID={sessionID() ?? ""}
|
||||||
messageID={message.id}
|
messageID={message.id}
|
||||||
lastUserMessageID={props.lastUserMessageID}
|
lastUserMessageID={props.lastUserMessageID}
|
||||||
classes={{
|
classes={{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export function SessionMobileTabs(props: {
|
export function SessionMobileTabs(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -8,8 +9,9 @@ export function SessionMobileTabs(props: {
|
|||||||
reviewCount: number
|
reviewCount: number
|
||||||
onSession: () => void
|
onSession: () => void
|
||||||
onChanges: () => void
|
onChanges: () => void
|
||||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
|
||||||
}) {
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<Tabs value={props.mobileTab} class="h-auto">
|
<Tabs value={props.mobileTab} class="h-auto">
|
||||||
@@ -20,7 +22,7 @@ export function SessionMobileTabs(props: {
|
|||||||
classes={{ button: "w-full" }}
|
classes={{ button: "w-full" }}
|
||||||
onClick={props.onSession}
|
onClick={props.onSession}
|
||||||
>
|
>
|
||||||
{props.t("session.tab.session")}
|
{language.t("session.tab.session")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="changes"
|
value="changes"
|
||||||
@@ -29,8 +31,8 @@ export function SessionMobileTabs(props: {
|
|||||||
onClick={props.onChanges}
|
onClick={props.onChanges}
|
||||||
>
|
>
|
||||||
{props.hasReview
|
{props.hasReview
|
||||||
? props.t("session.review.filesChanged", { count: props.reviewCount })
|
? language.t("session.review.filesChanged", { count: props.reviewCount })
|
||||||
: props.t("session.review.change.other")}
|
: language.t("session.review.change.other")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,35 +1,105 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||||
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { QuestionDock } from "@/components/question-dock"
|
import { QuestionDock } from "@/components/question-dock"
|
||||||
import { SessionTodoDock } from "@/components/session-todo-dock"
|
import { SessionTodoDock } from "@/components/session-todo-dock"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
|
|
||||||
export function SessionPromptDock(props: {
|
export function SessionPromptDock(props: {
|
||||||
centered: boolean
|
centered: boolean
|
||||||
questionRequest: () => QuestionRequest | undefined
|
|
||||||
permissionRequest: () => { patterns: string[]; permission: string } | undefined
|
|
||||||
blocked: boolean
|
|
||||||
todos: Todo[]
|
|
||||||
promptReady: boolean
|
|
||||||
handoffPrompt?: string
|
|
||||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
|
||||||
responding: boolean
|
|
||||||
onDecide: (response: "once" | "always" | "reject") => void
|
|
||||||
inputRef: (el: HTMLDivElement) => void
|
inputRef: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree: string
|
newSessionWorktree: string
|
||||||
onNewSessionWorktreeReset: () => void
|
onNewSessionWorktreeReset: () => void
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
setPromptDockRef: (el: HTMLDivElement) => void
|
||||||
}) {
|
}) {
|
||||||
const done = createMemo(
|
const params = useParams()
|
||||||
() =>
|
const sdk = useSDK()
|
||||||
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
const sync = useSync()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
||||||
|
|
||||||
|
const todos = createMemo((): Todo[] => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return []
|
||||||
|
return globalSync.data.session_todo[id] ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
return sync.data.question[sessionID]?.[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
return sync.data.permission[sessionID]?.[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
|
||||||
|
|
||||||
|
const previewPrompt = () =>
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
|
if (part.type === "agent") return `@${part.name}`
|
||||||
|
if (part.type === "image") return `[image:${part.filename}]`
|
||||||
|
return part.content
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||||
|
})
|
||||||
|
|
||||||
|
const [responding, setResponding] = createSignal(false)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => permissionRequest()?.id,
|
||||||
|
() => setResponding(false),
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [dock, setDock] = createSignal(props.todos.length > 0)
|
const decide = (response: "once" | "always" | "reject") => {
|
||||||
|
const perm = permissionRequest()
|
||||||
|
if (!perm) return
|
||||||
|
if (responding()) return
|
||||||
|
|
||||||
|
setResponding(true)
|
||||||
|
sdk.client.permission
|
||||||
|
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
.finally(() => setResponding(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = createMemo(
|
||||||
|
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [dock, setDock] = createSignal(todos().length > 0)
|
||||||
const [closing, setClosing] = createSignal(false)
|
const [closing, setClosing] = createSignal(false)
|
||||||
const [opening, setOpening] = createSignal(false)
|
const [opening, setOpening] = createSignal(false)
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
@@ -46,7 +116,7 @@ export function SessionPromptDock(props: {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [props.todos.length, done()] as const,
|
() => [todos().length, done()] as const,
|
||||||
([count, complete], prev) => {
|
([count, complete], prev) => {
|
||||||
if (raf) cancelAnimationFrame(raf)
|
if (raf) cancelAnimationFrame(raf)
|
||||||
raf = undefined
|
raf = undefined
|
||||||
@@ -113,7 +183,7 @@ export function SessionPromptDock(props: {
|
|||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.questionRequest()} keyed>
|
<Show when={questionRequest()} keyed>
|
||||||
{(req) => {
|
{(req) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -123,11 +193,11 @@ export function SessionPromptDock(props: {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.permissionRequest()} keyed>
|
<Show when={permissionRequest()} keyed>
|
||||||
{(perm) => {
|
{(perm) => {
|
||||||
const toolDescription = () => {
|
const toolDescription = () => {
|
||||||
const key = `settings.permissions.tool.${perm.permission}.description`
|
const key = `settings.permissions.tool.${perm.permission}.description`
|
||||||
const value = props.t(key)
|
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||||
if (value === key) return ""
|
if (value === key) return ""
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -141,36 +211,26 @@ export function SessionPromptDock(props: {
|
|||||||
<span data-slot="permission-icon">
|
<span data-slot="permission-icon">
|
||||||
<Icon name="warning" size="normal" />
|
<Icon name="warning" size="normal" />
|
||||||
</span>
|
</span>
|
||||||
<div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
|
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<div />
|
<div />
|
||||||
<div data-slot="permission-footer-actions">
|
<div data-slot="permission-footer-actions">
|
||||||
<Button
|
<Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
|
||||||
variant="ghost"
|
{language.t("ui.permission.deny")}
|
||||||
size="normal"
|
|
||||||
onClick={() => props.onDecide("reject")}
|
|
||||||
disabled={props.responding}
|
|
||||||
>
|
|
||||||
{props.t("ui.permission.deny")}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="normal"
|
size="normal"
|
||||||
onClick={() => props.onDecide("always")}
|
onClick={() => decide("always")}
|
||||||
disabled={props.responding}
|
disabled={responding()}
|
||||||
>
|
>
|
||||||
{props.t("ui.permission.allowAlways")}
|
{language.t("ui.permission.allowAlways")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
|
||||||
variant="primary"
|
{language.t("ui.permission.allowOnce")}
|
||||||
size="normal"
|
|
||||||
onClick={() => props.onDecide("once")}
|
|
||||||
disabled={props.responding}
|
|
||||||
>
|
|
||||||
{props.t("ui.permission.allowOnce")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -199,12 +259,12 @@ export function SessionPromptDock(props: {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!props.blocked}>
|
<Show when={!blocked()}>
|
||||||
<Show
|
<Show
|
||||||
when={props.promptReady}
|
when={prompt.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||||
{props.handoffPrompt || props.t("prompt.loading")}
|
{handoffPrompt() || language.t("prompt.loading")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -219,10 +279,10 @@ export function SessionPromptDock(props: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionTodoDock
|
<SessionTodoDock
|
||||||
todos={props.todos}
|
todos={todos()}
|
||||||
title={props.t("session.todo.title")}
|
title={language.t("session.todo.title")}
|
||||||
collapseLabel={props.t("session.todo.collapse")}
|
collapseLabel={language.t("session.todo.collapse")}
|
||||||
expandLabel={props.t("session.todo.expand")}
|
expandLabel={language.t("session.todo.expand")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,156 +1,269 @@
|
|||||||
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
import FileTree from "@/components/file-tree"
|
import FileTree from "@/components/file-tree"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
|
||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
|
||||||
import { StickyAddButton } from "@/pages/session/review-tab"
|
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
|
||||||
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
|
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
|
||||||
import { useComments } from "@/context/comments"
|
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
type SessionSidePanelViewModel = {
|
import { getTabReorderIndex } from "@/pages/session/helpers"
|
||||||
messages: () => Message[]
|
import { StickyAddButton } from "@/pages/session/review-tab"
|
||||||
visibleUserMessages: () => UserMessage[]
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
|
||||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
open: boolean
|
|
||||||
reviewOpen: boolean
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
layout: ReturnType<typeof useLayout>
|
|
||||||
command: ReturnType<typeof useCommand>
|
|
||||||
dialog: ReturnType<typeof useDialog>
|
|
||||||
file: ReturnType<typeof useFile>
|
|
||||||
comments: ReturnType<typeof useComments>
|
|
||||||
hasReview: boolean
|
|
||||||
reviewCount: number
|
|
||||||
reviewTab: boolean
|
|
||||||
contextOpen: () => boolean
|
|
||||||
openedTabs: () => string[]
|
|
||||||
activeTab: () => string
|
|
||||||
activeFileTab: () => string | undefined
|
|
||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
|
||||||
openTab: (value: string) => void
|
|
||||||
showAllFiles: () => void
|
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
vm: SessionSidePanelViewModel
|
|
||||||
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
|
||||||
codeComponent: NonNullable<ValidComponent>
|
|
||||||
addCommentToContext: (input: {
|
|
||||||
file: string
|
|
||||||
selection: SelectedLineRange
|
|
||||||
comment: string
|
|
||||||
preview?: string
|
|
||||||
origin?: "review" | "file"
|
|
||||||
}) => void
|
|
||||||
activeDraggable: () => string | undefined
|
|
||||||
onDragStart: (event: unknown) => void
|
|
||||||
onDragEnd: () => void
|
|
||||||
onDragOver: (event: DragEvent) => void
|
|
||||||
fileTreeTab: () => "changes" | "all"
|
|
||||||
setFileTreeTabValue: (value: string) => void
|
|
||||||
diffsReady: boolean
|
|
||||||
diffFiles: string[]
|
|
||||||
kinds: Map<string, "add" | "del" | "mix">
|
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
}) {
|
}) {
|
||||||
const openedTabs = createMemo(() => props.openedTabs())
|
const params = useParams()
|
||||||
|
const layout = useLayout()
|
||||||
|
const sync = useSync()
|
||||||
|
const file = useFile()
|
||||||
|
const language = useLanguage()
|
||||||
|
const command = useCommand()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||||
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
|
|
||||||
|
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
|
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
|
||||||
|
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
|
||||||
|
|
||||||
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
|
const diffsReady = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return true
|
||||||
|
if (!hasReview()) return true
|
||||||
|
return sync.data.session_diff[id] !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||||
|
const kinds = createMemo(() => {
|
||||||
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
|
if (!a) return b
|
||||||
|
if (a === b) return a
|
||||||
|
return "mix" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||||
|
|
||||||
|
const out = new Map<string, "add" | "del" | "mix">()
|
||||||
|
for (const diff of diffs()) {
|
||||||
|
const file = normalize(diff.file)
|
||||||
|
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||||
|
|
||||||
|
out.set(file, kind)
|
||||||
|
|
||||||
|
const parts = file.split("/")
|
||||||
|
for (const [idx] of parts.slice(0, -1).entries()) {
|
||||||
|
const dir = parts.slice(0, idx + 1).join("/")
|
||||||
|
if (!dir) continue
|
||||||
|
out.set(dir, merge(out.get(dir), kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeTab = (tab: string) => {
|
||||||
|
if (!tab.startsWith("file://")) return tab
|
||||||
|
return file.tab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReviewPanel = () => {
|
||||||
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTab = (value: string) => {
|
||||||
|
const next = normalizeTab(value)
|
||||||
|
tabs().open(next)
|
||||||
|
|
||||||
|
const path = file.pathFromTab(next)
|
||||||
|
if (!path) return
|
||||||
|
file.load(path)
|
||||||
|
openReviewPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
||||||
|
const openedTabs = createMemo(() =>
|
||||||
|
tabs()
|
||||||
|
.all()
|
||||||
|
.filter((tab) => tab !== "context" && tab !== "review"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeTab = createMemo(() => {
|
||||||
|
const active = tabs().active()
|
||||||
|
if (active === "context") return "context"
|
||||||
|
if (active === "review" && reviewTab()) return "review"
|
||||||
|
if (active && file.pathFromTab(active)) return normalizeTab(active)
|
||||||
|
|
||||||
|
const first = openedTabs()[0]
|
||||||
|
if (first) return first
|
||||||
|
if (contextOpen()) return "context"
|
||||||
|
if (reviewTab() && hasReview()) return "review"
|
||||||
|
return "empty"
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeFileTab = createMemo(() => {
|
||||||
|
const active = activeTab()
|
||||||
|
if (!openedTabs().includes(active)) return
|
||||||
|
return active
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileTreeTab = () => layout.fileTree.tab()
|
||||||
|
|
||||||
|
const setFileTreeTabValue = (value: string) => {
|
||||||
|
if (value !== "changes" && value !== "all") return
|
||||||
|
layout.fileTree.setTab(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAllFiles = () => {
|
||||||
|
if (fileTreeTab() !== "changes") return
|
||||||
|
layout.fileTree.setTab("all")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
activeDraggable: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDragStart = (event: unknown) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
setStore("activeDraggable", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
const { draggable, droppable } = event
|
||||||
|
if (!draggable || !droppable) return
|
||||||
|
|
||||||
|
const currentTabs = tabs().all()
|
||||||
|
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
|
||||||
|
if (toIndex === undefined) return
|
||||||
|
tabs().move(draggable.id.toString(), toIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setStore("activeDraggable", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!file.ready()) return
|
||||||
|
|
||||||
|
setSessionHandoff(sessionKey(), {
|
||||||
|
files: tabs()
|
||||||
|
.all()
|
||||||
|
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
|
||||||
|
const path = file.pathFromTab(tab)
|
||||||
|
if (!path) return acc
|
||||||
|
|
||||||
|
const selected = file.selectedLines(path)
|
||||||
|
acc[path] =
|
||||||
|
selected && typeof selected === "object" && "start" in selected && "end" in selected
|
||||||
|
? (selected as SelectedLineRange)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.open}>
|
<Show when={open()}>
|
||||||
<aside
|
<aside
|
||||||
id="review-panel"
|
id="review-panel"
|
||||||
aria-label={props.language.t("session.panel.reviewAndFiles")}
|
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||||
class="relative min-w-0 h-full border-l border-border-weak-base flex"
|
class="relative min-w-0 h-full border-l border-border-weak-base flex"
|
||||||
classList={{
|
classList={{
|
||||||
"flex-1": props.reviewOpen,
|
"flex-1": reviewOpen(),
|
||||||
"shrink-0": !props.reviewOpen,
|
"shrink-0": !reviewOpen(),
|
||||||
}}
|
}}
|
||||||
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
|
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
|
||||||
>
|
>
|
||||||
<Show when={props.reviewOpen}>
|
<Show when={reviewOpen()}>
|
||||||
<div class="flex-1 min-w-0 h-full">
|
<div class="flex-1 min-w-0 h-full">
|
||||||
<Show
|
<Show
|
||||||
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
|
when={layout.fileTree.opened() && fileTreeTab() === "changes"}
|
||||||
fallback={
|
fallback={
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={props.onDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={props.onDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragOver={props.onDragOver}
|
onDragOver={handleDragOver}
|
||||||
collisionDetector={closestCenter}
|
collisionDetector={closestCenter}
|
||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
<ConstrainDragYAxis />
|
<ConstrainDragYAxis />
|
||||||
<Tabs value={props.activeTab()} onChange={props.openTab}>
|
<Tabs value={activeTab()} onChange={openTab}>
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
<Tabs.List
|
<Tabs.List
|
||||||
ref={(el: HTMLDivElement) => {
|
ref={(el: HTMLDivElement) => {
|
||||||
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
|
const stop = createFileTabListSync({ el, contextOpen })
|
||||||
onCleanup(stop)
|
onCleanup(stop)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.reviewTab}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
|
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<div>{props.language.t("session.tab.review")}</div>
|
<div>{language.t("session.tab.review")}</div>
|
||||||
<Show when={props.hasReview}>
|
<Show when={hasReview()}>
|
||||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||||
{props.reviewCount}
|
{reviewCount()}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.contextOpen()}>
|
<Show when={contextOpen()}>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="context"
|
value="context"
|
||||||
closeButton={
|
closeButton={
|
||||||
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
|
<Tooltip value={language.t("common.closeTab")} placement="bottom">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="close-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
onClick={() => props.tabs().close("context")}
|
onClick={() => tabs().close("context")}
|
||||||
aria-label={props.language.t("common.closeTab")}
|
aria-label={language.t("common.closeTab")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onMiddleClick={() => props.tabs().close("context")}
|
onMiddleClick={() => tabs().close("context")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<SessionContextUsage variant="indicator" />
|
<SessionContextUsage variant="indicator" />
|
||||||
<div>{props.language.t("session.tab.context")}</div>
|
<div>{language.t("session.tab.context")}</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Show>
|
</Show>
|
||||||
<SortableProvider ids={openedTabs()}>
|
<SortableProvider ids={openedTabs()}>
|
||||||
<For each={openedTabs()}>
|
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||||
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
|
|
||||||
</For>
|
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<StickyAddButton>
|
<StickyAddButton>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={props.language.t("command.file.open")}
|
title={language.t("command.file.open")}
|
||||||
keybind={props.command.keybind("file.open")}
|
keybind={command.keybind("file.open")}
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -158,72 +271,52 @@ export function SessionSidePanel(props: {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconSize="large"
|
iconSize="large"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.dialog.show(() => (
|
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||||
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
aria-label={props.language.t("command.file.open")}
|
aria-label={language.t("command.file.open")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</StickyAddButton>
|
</StickyAddButton>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.reviewTab}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
|
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={props.activeTab() === "empty"}>
|
<Show when={activeTab() === "empty"}>
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<Mark class="w-14 opacity-10" />
|
<Mark class="w-14 opacity-10" />
|
||||||
<div class="text-14-regular text-text-weak max-w-56">
|
<div class="text-14-regular text-text-weak max-w-56">
|
||||||
{props.language.t("session.files.selectToOpen")}
|
{language.t("session.files.selectToOpen")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Show when={props.contextOpen()}>
|
<Show when={contextOpen()}>
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={props.activeTab() === "context"}>
|
<Show when={activeTab() === "context"}>
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab
|
<SessionContextTab />
|
||||||
messages={props.vm.messages}
|
|
||||||
visibleUserMessages={props.vm.visibleUserMessages}
|
|
||||||
view={props.vm.view}
|
|
||||||
info={props.vm.info}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.activeFileTab()} keyed>
|
<Show when={activeFileTab()} keyed>
|
||||||
{(tab) => (
|
{(tab) => <FileTabContent tab={tab} />}
|
||||||
<FileTabContent
|
|
||||||
tab={tab}
|
|
||||||
activeTab={props.activeTab}
|
|
||||||
tabs={props.tabs}
|
|
||||||
view={props.vm.view}
|
|
||||||
handoffFiles={props.handoffFiles}
|
|
||||||
file={props.file}
|
|
||||||
comments={props.comments}
|
|
||||||
language={props.language}
|
|
||||||
codeComponent={props.codeComponent}
|
|
||||||
addCommentToContext={props.addCommentToContext}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
<Show when={props.activeDraggable()}>
|
<Show when={store.activeDraggable} keyed>
|
||||||
{(tab) => {
|
{(tab) => {
|
||||||
const path = createMemo(() => props.file.pathFromTab(tab()))
|
const path = createMemo(() => file.pathFromTab(tab))
|
||||||
return (
|
return (
|
||||||
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
||||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||||
@@ -240,50 +333,44 @@ export function SessionSidePanel(props: {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.layout.fileTree.opened()}>
|
<Show when={layout.fileTree.opened()}>
|
||||||
<div
|
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
||||||
id="file-tree-panel"
|
|
||||||
class="relative shrink-0 h-full"
|
|
||||||
style={{ width: `${props.layout.fileTree.width()}px` }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||||
classList={{ "border-l border-border-weak-base": props.reviewOpen }}
|
classList={{ "border-l border-border-weak-base": reviewOpen() }}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
variant="pill"
|
variant="pill"
|
||||||
value={props.fileTreeTab()}
|
value={fileTreeTab()}
|
||||||
onChange={props.setFileTreeTabValue}
|
onChange={setFileTreeTabValue}
|
||||||
class="h-full"
|
class="h-full"
|
||||||
data-scope="filetree"
|
data-scope="filetree"
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{props.reviewCount}{" "}
|
{reviewCount()}{" "}
|
||||||
{props.language.t(
|
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||||
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
|
|
||||||
)}
|
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{props.language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
|
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.hasReview}>
|
<Match when={hasReview()}>
|
||||||
<Show
|
<Show
|
||||||
when={props.diffsReady}
|
when={diffsReady()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
{props.language.t("common.loading")}
|
{language.t("common.loading")}
|
||||||
{props.language.t("common.loading.ellipsis")}
|
{language.t("common.loading.ellipsis")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileTree
|
<FileTree
|
||||||
path=""
|
path=""
|
||||||
allowed={props.diffFiles}
|
allowed={diffFiles()}
|
||||||
kinds={props.kinds}
|
kinds={kinds()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
active={props.activeDiff}
|
active={props.activeDiff}
|
||||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||||
@@ -292,7 +379,7 @@ export function SessionSidePanel(props: {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
<div class="mt-8 text-center text-12-regular text-text-weak">
|
||||||
{props.language.t("session.review.noChanges")}
|
{language.t("session.review.noChanges")}
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -300,9 +387,9 @@ export function SessionSidePanel(props: {
|
|||||||
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
|
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
|
||||||
<FileTree
|
<FileTree
|
||||||
path=""
|
path=""
|
||||||
modified={props.diffFiles}
|
modified={diffFiles()}
|
||||||
kinds={props.kinds}
|
kinds={kinds()}
|
||||||
onFileClick={(node) => props.openTab(props.file.tab(node.path))}
|
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -310,12 +397,12 @@ export function SessionSidePanel(props: {
|
|||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
edge="start"
|
edge="start"
|
||||||
size={props.layout.fileTree.width()}
|
size={layout.fileTree.width()}
|
||||||
min={200}
|
min={200}
|
||||||
max={480}
|
max={480}
|
||||||
collapseThreshold={160}
|
collapseThreshold={160}
|
||||||
onResize={props.layout.fileTree.resize}
|
onResize={layout.fileTree.resize}
|
||||||
onCollapse={props.layout.fileTree.close}
|
onCollapse={layout.fileTree.close}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,61 +1,161 @@
|
|||||||
import { For, Show, createMemo } from "solid-js"
|
import { For, Show, createEffect, createMemo, on } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
|
|
||||||
import { SortableTerminalTab } from "@/components/session"
|
import { SortableTerminalTab } from "@/components/session"
|
||||||
import { Terminal } from "@/components/terminal"
|
import { Terminal } from "@/components/terminal"
|
||||||
import { useTerminal } from "@/context/terminal"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||||
|
|
||||||
export function TerminalPanel(props: {
|
export function TerminalPanel() {
|
||||||
open: boolean
|
const params = useParams()
|
||||||
height: number
|
const layout = useLayout()
|
||||||
resize: (value: number) => void
|
const terminal = useTerminal()
|
||||||
close: () => void
|
const language = useLanguage()
|
||||||
terminal: ReturnType<typeof useTerminal>
|
const command = useCommand()
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
command: ReturnType<typeof useCommand>
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
handoff: () => string[]
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
activeTerminalDraggable: () => string | undefined
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
handleTerminalDragStart: (event: unknown) => void
|
|
||||||
handleTerminalDragOver: (event: DragEvent) => void
|
const opened = createMemo(() => view().terminal.opened())
|
||||||
handleTerminalDragEnd: () => void
|
const open = createMemo(() => isDesktop() && opened())
|
||||||
onCloseTab: () => void
|
const height = createMemo(() => layout.terminal.height())
|
||||||
}) {
|
const close = () => view().terminal.close()
|
||||||
const all = createMemo(() => props.terminal.all())
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
autoCreated: false,
|
||||||
|
activeDraggable: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!opened()) {
|
||||||
|
setStore("autoCreated", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
||||||
|
terminal.new()
|
||||||
|
setStore("autoCreated", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => terminal.all().length,
|
||||||
|
(count, prevCount) => {
|
||||||
|
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
||||||
|
if (opened()) view().terminal.toggle()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => terminal.active(),
|
||||||
|
(activeId) => {
|
||||||
|
if (!activeId || !opened()) return
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur()
|
||||||
|
}
|
||||||
|
focusTerminalById(activeId)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const dir = params.dir
|
||||||
|
if (!dir) return
|
||||||
|
if (!terminal.ready()) return
|
||||||
|
language.locale()
|
||||||
|
|
||||||
|
setTerminalHandoff(
|
||||||
|
dir,
|
||||||
|
terminal.all().map((pty) =>
|
||||||
|
terminalTabLabel({
|
||||||
|
title: pty.title,
|
||||||
|
titleNumber: pty.titleNumber,
|
||||||
|
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handoff = createMemo(() => {
|
||||||
|
const dir = params.dir
|
||||||
|
if (!dir) return []
|
||||||
|
return getTerminalHandoff(dir) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = createMemo(() => terminal.all())
|
||||||
const ids = createMemo(() => all().map((pty) => pty.id))
|
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||||
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
|
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
|
||||||
|
|
||||||
|
const handleTerminalDragStart = (event: unknown) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
setStore("activeDraggable", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTerminalDragOver = (event: DragEvent) => {
|
||||||
|
const { draggable, droppable } = event
|
||||||
|
if (!draggable || !droppable) return
|
||||||
|
|
||||||
|
const terminals = terminal.all()
|
||||||
|
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
||||||
|
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
||||||
|
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||||
|
terminal.move(draggable.id.toString(), toIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTerminalDragEnd = () => {
|
||||||
|
setStore("activeDraggable", undefined)
|
||||||
|
|
||||||
|
const activeId = terminal.active()
|
||||||
|
if (!activeId) return
|
||||||
|
setTimeout(() => {
|
||||||
|
focusTerminalById(activeId)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.open}>
|
<Show when={open()}>
|
||||||
<div
|
<div
|
||||||
id="terminal-panel"
|
id="terminal-panel"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={props.language.t("terminal.title")}
|
aria-label={language.t("terminal.title")}
|
||||||
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
||||||
style={{ height: `${props.height}px` }}
|
style={{ height: `${height()}px` }}
|
||||||
>
|
>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
size={props.height}
|
size={height()}
|
||||||
min={100}
|
min={100}
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||||
collapseThreshold={50}
|
collapseThreshold={50}
|
||||||
onResize={props.resize}
|
onResize={layout.terminal.resize}
|
||||||
onCollapse={props.close}
|
onCollapse={close}
|
||||||
/>
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={props.terminal.ready()}
|
when={terminal.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col h-full pointer-events-none">
|
<div class="flex flex-col h-full pointer-events-none">
|
||||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||||
<For each={props.handoff()}>
|
<For each={handoff()}>
|
||||||
{(title) => (
|
{(title) => (
|
||||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
{title}
|
{title}
|
||||||
@@ -64,20 +164,18 @@ export function TerminalPanel(props: {
|
|||||||
</For>
|
</For>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<div class="text-text-weak pr-2">
|
<div class="text-text-weak pr-2">
|
||||||
{props.language.t("common.loading")}
|
{language.t("common.loading")}
|
||||||
{props.language.t("common.loading.ellipsis")}
|
{language.t("common.loading.ellipsis")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex items-center justify-center text-text-weak">
|
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
||||||
{props.language.t("terminal.loading")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={props.handleTerminalDragStart}
|
onDragStart={handleTerminalDragStart}
|
||||||
onDragEnd={props.handleTerminalDragEnd}
|
onDragEnd={handleTerminalDragEnd}
|
||||||
onDragOver={props.handleTerminalDragOver}
|
onDragOver={handleTerminalDragOver}
|
||||||
collisionDetector={closestCenter}
|
collisionDetector={closestCenter}
|
||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
@@ -85,36 +183,26 @@ export function TerminalPanel(props: {
|
|||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<Tabs
|
<Tabs
|
||||||
variant="alt"
|
variant="alt"
|
||||||
value={props.terminal.active()}
|
value={terminal.active()}
|
||||||
onChange={(id) => props.terminal.open(id)}
|
onChange={(id) => terminal.open(id)}
|
||||||
class="!h-auto !flex-none"
|
class="!h-auto !flex-none"
|
||||||
>
|
>
|
||||||
<Tabs.List class="h-10">
|
<Tabs.List class="h-10">
|
||||||
<SortableProvider ids={ids()}>
|
<SortableProvider ids={ids()}>
|
||||||
<For each={all()}>
|
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
|
||||||
{(pty) => (
|
|
||||||
<SortableTerminalTab
|
|
||||||
terminal={pty}
|
|
||||||
onClose={() => {
|
|
||||||
props.close()
|
|
||||||
props.onCloseTab()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={props.language.t("command.terminal.new")}
|
title={language.t("command.terminal.new")}
|
||||||
keybind={props.command.keybind("terminal.new")}
|
keybind={command.keybind("terminal.new")}
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconSize="large"
|
iconSize="large"
|
||||||
onClick={props.terminal.new}
|
onClick={terminal.new}
|
||||||
aria-label={props.language.t("command.terminal.new")}
|
aria-label={language.t("command.terminal.new")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,15 +215,11 @@ export function TerminalPanel(props: {
|
|||||||
id={`terminal-wrapper-${pty.id}`}
|
id={`terminal-wrapper-${pty.id}`}
|
||||||
class="absolute inset-0"
|
class="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
display: props.terminal.active() === pty.id ? "block" : "none",
|
display: terminal.active() === pty.id ? "block" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={pty.id} keyed>
|
<Show when={pty.id} keyed>
|
||||||
<Terminal
|
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||||
pty={pty}
|
|
||||||
onCleanup={props.terminal.update}
|
|
||||||
onConnectError={() => props.terminal.clone(pty.id)}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -143,25 +227,20 @@ export function TerminalPanel(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
<Show when={props.activeTerminalDraggable()}>
|
<Show when={store.activeDraggable}>
|
||||||
{(draggedId) => {
|
{(draggedId) => (
|
||||||
return (
|
<Show when={byId().get(draggedId())}>
|
||||||
<Show when={byId().get(draggedId())}>
|
{(t) => (
|
||||||
{(t) => (
|
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
{terminalTabLabel({
|
||||||
{terminalTabLabel({
|
title: t().title,
|
||||||
title: t().title,
|
titleNumber: t().titleNumber,
|
||||||
titleNumber: t().titleNumber,
|
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||||
t: props.language.t as (
|
})}
|
||||||
key: string,
|
</div>
|
||||||
vars?: Record<string, string | number | boolean>,
|
)}
|
||||||
) => string,
|
</Show>
|
||||||
})}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
</Show>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user