mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 17:54:23 +01:00
wip: desktop work
This commit is contained in:
@@ -19,7 +19,7 @@ export default function FileTree(props: {
|
|||||||
component={p.as ?? "div"}
|
component={p.as ?? "div"}
|
||||||
classList={{
|
classList={{
|
||||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
||||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
// "bg-background-element": local.file.active()?.path === p.node.path,
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||||
}}
|
}}
|
||||||
style={`padding-left: ${level * 10}px`}
|
style={`padding-left: ${level * 10}px`}
|
||||||
@@ -55,7 +55,7 @@ export default function FileTree(props: {
|
|||||||
"text-xs whitespace-nowrap truncate": true,
|
"text-xs whitespace-nowrap truncate": true,
|
||||||
"text-text-muted/40": p.node.ignored,
|
"text-text-muted/40": p.node.ignored,
|
||||||
"text-text-muted/80": !p.node.ignored,
|
"text-text-muted/80": !p.node.ignored,
|
||||||
"!text-text": local.file.active()?.path === p.node.path,
|
// "!text-text": local.file.active()?.path === p.node.path,
|
||||||
"!text-primary": local.file.changed(p.node.path),
|
"!text-primary": local.file.changed(p.node.path),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { pipe, sumBy, uniqueBy } from "remeda"
|
import { uniqueBy } from "remeda"
|
||||||
import type {
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||||
FileContent,
|
|
||||||
FileNode,
|
|
||||||
Model,
|
|
||||||
Provider,
|
|
||||||
File as FileStatus,
|
|
||||||
Part,
|
|
||||||
Message,
|
|
||||||
AssistantMessage,
|
|
||||||
} from "@opencode-ai/sdk"
|
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
@@ -204,18 +195,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const file = (() => {
|
const file = (() => {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
node: Record<string, LocalFile>
|
node: Record<string, LocalFile>
|
||||||
opened: string[]
|
// opened: string[]
|
||||||
active?: string
|
// active?: string
|
||||||
}>({
|
}>({
|
||||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||||
opened: [],
|
// opened: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const active = createMemo(() => {
|
// const active = createMemo(() => {
|
||||||
if (!store.active) return undefined
|
// if (!store.active) return undefined
|
||||||
return store.node[store.active]
|
// return store.node[store.active]
|
||||||
})
|
// })
|
||||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
// const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||||
|
|
||||||
@@ -303,16 +294,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
if (!store.node[relativePath]) await fetch(path)
|
if (!store.node[relativePath]) await fetch(path)
|
||||||
setStore("opened", (x) => {
|
// setStore("opened", (x) => {
|
||||||
if (x.includes(relativePath)) return x
|
// if (x.includes(relativePath)) return x
|
||||||
return [
|
// return [
|
||||||
...opened()
|
// ...opened()
|
||||||
.filter((x) => x.pinned)
|
// .filter((x) => x.pinned)
|
||||||
.map((x) => x.path),
|
// .map((x) => x.path),
|
||||||
relativePath,
|
// relativePath,
|
||||||
]
|
// ]
|
||||||
})
|
// })
|
||||||
setStore("active", relativePath)
|
// setStore("active", relativePath)
|
||||||
context.addActive()
|
context.addActive()
|
||||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||||
@@ -363,22 +354,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active,
|
|
||||||
opened,
|
|
||||||
node: (path: string) => store.node[path],
|
node: (path: string) => store.node[path],
|
||||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||||
open,
|
open,
|
||||||
load,
|
load,
|
||||||
init,
|
init,
|
||||||
close(path: string) {
|
|
||||||
setStore("opened", (opened) => opened.filter((x) => x !== path))
|
|
||||||
if (store.active === path) {
|
|
||||||
const index = store.opened.findIndex((f) => f === path)
|
|
||||||
const previous = store.opened[Math.max(0, index - 1)]
|
|
||||||
setStore("active", previous)
|
|
||||||
}
|
|
||||||
resetNode(path)
|
|
||||||
},
|
|
||||||
expand(path: string) {
|
expand(path: string) {
|
||||||
setStore("node", path, "expanded", true)
|
setStore("node", path, "expanded", true)
|
||||||
if (store.node[path].loaded) return
|
if (store.node[path].loaded) return
|
||||||
@@ -394,17 +374,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
scroll(path: string, scrollTop: number) {
|
scroll(path: string, scrollTop: number) {
|
||||||
setStore("node", path, "scrollTop", scrollTop)
|
setStore("node", path, "scrollTop", scrollTop)
|
||||||
},
|
},
|
||||||
move(path: string, to: number) {
|
|
||||||
const index = store.opened.findIndex((f) => f === path)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"opened",
|
|
||||||
produce((opened) => {
|
|
||||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setStore("node", path, "pinned", true)
|
|
||||||
},
|
|
||||||
view(path: string): View {
|
view(path: string): View {
|
||||||
const n = store.node[path]
|
const n = store.node[path]
|
||||||
return n && n.view ? n.view : "raw"
|
return n && n.view ? n.view : "raw"
|
||||||
@@ -444,14 +413,48 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
search,
|
search,
|
||||||
relative,
|
relative,
|
||||||
|
// active,
|
||||||
|
// opened,
|
||||||
|
// close(path: string) {
|
||||||
|
// setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||||
|
// if (store.active === path) {
|
||||||
|
// const index = store.opened.findIndex((f) => f === path)
|
||||||
|
// const previous = store.opened[Math.max(0, index - 1)]
|
||||||
|
// setStore("active", previous)
|
||||||
|
// }
|
||||||
|
// resetNode(path)
|
||||||
|
// },
|
||||||
|
// move(path: string, to: number) {
|
||||||
|
// const index = store.opened.findIndex((f) => f === path)
|
||||||
|
// if (index === -1) return
|
||||||
|
// setStore(
|
||||||
|
// "opened",
|
||||||
|
// produce((opened) => {
|
||||||
|
// opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||||
|
// }),
|
||||||
|
// )
|
||||||
|
// setStore("node", path, "pinned", true)
|
||||||
|
// },
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const session = (() => {
|
const session = (() => {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
activeMessage?: string
|
tabs: Record<
|
||||||
}>({})
|
string,
|
||||||
|
{
|
||||||
|
active?: string
|
||||||
|
opened: string[]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}>({
|
||||||
|
tabs: {
|
||||||
|
"": {
|
||||||
|
opened: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const active = createMemo(() => {
|
const active = createMemo(() => {
|
||||||
if (!store.active) return undefined
|
if (!store.active) return undefined
|
||||||
@@ -461,134 +464,69 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!store.active) return
|
if (!store.active) return
|
||||||
sync.session.sync(store.active)
|
sync.session.sync(store.active)
|
||||||
})
|
|
||||||
|
|
||||||
const valid = (part: Part) => {
|
if (!store.tabs[store.active]) {
|
||||||
if (!part) return false
|
setStore("tabs", store.active, {
|
||||||
switch (part.type) {
|
opened: [],
|
||||||
case "step-start":
|
})
|
||||||
case "step-finish":
|
|
||||||
case "file":
|
|
||||||
case "patch":
|
|
||||||
return false
|
|
||||||
case "text":
|
|
||||||
return !part.synthetic && part.text.trim()
|
|
||||||
case "reasoning":
|
|
||||||
return part.text.trim()
|
|
||||||
case "tool":
|
|
||||||
switch (part.tool) {
|
|
||||||
case "todoread":
|
|
||||||
case "todowrite":
|
|
||||||
case "list":
|
|
||||||
case "grep":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const hasValidParts = (message: Message) => {
|
|
||||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
|
||||||
}
|
|
||||||
// const hasTextPart = (message: Message) => {
|
|
||||||
// return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
|
||||||
// }
|
|
||||||
|
|
||||||
const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
|
|
||||||
const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
|
|
||||||
const userMessages = createMemo(() =>
|
|
||||||
messages()
|
|
||||||
.filter((m) => m.role === "user")
|
|
||||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
|
||||||
const total = pipe(
|
|
||||||
messages(),
|
|
||||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
|
||||||
)
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(total)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const last = createMemo(() => {
|
const tabs = createMemo(() => store.tabs[store.active ?? ""])
|
||||||
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastUserMessage = createMemo(() => {
|
|
||||||
return userMessages()?.at(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeMessage = createMemo(() => {
|
|
||||||
if (!store.active || !store.activeMessage) return lastUserMessage()
|
|
||||||
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
const model = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
|
||||||
return model
|
|
||||||
})
|
|
||||||
|
|
||||||
const tokens = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
const tokens = last().tokens
|
|
||||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
notation: "compact",
|
|
||||||
compactDisplay: "short",
|
|
||||||
}).format(total)
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
if (!model()?.limit.context) return 0
|
|
||||||
const tokens = last().tokens
|
|
||||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
|
||||||
return Math.round((total / model()!.limit.context) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const getMessageText = (message: Message | Message[] | undefined): string => {
|
|
||||||
if (!message) return ""
|
|
||||||
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
|
|
||||||
return sync.data.part[message.id]
|
|
||||||
?.filter((p) => p.type === "text")
|
|
||||||
?.filter((p) => !p.synthetic)
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active,
|
active,
|
||||||
activeMessage,
|
|
||||||
lastUserMessage,
|
|
||||||
cost,
|
|
||||||
last,
|
|
||||||
model,
|
|
||||||
tokens,
|
|
||||||
context,
|
|
||||||
messages,
|
|
||||||
messagesWithValidParts,
|
|
||||||
userMessages,
|
|
||||||
// working,
|
|
||||||
getMessageText,
|
|
||||||
setActive(sessionId: string | undefined) {
|
setActive(sessionId: string | undefined) {
|
||||||
setStore("active", sessionId)
|
setStore("active", sessionId)
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
},
|
||||||
clearActive() {
|
clearActive() {
|
||||||
setStore("active", undefined)
|
setStore("active", undefined)
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
},
|
||||||
setActiveMessage(messageId: string | undefined) {
|
tabs,
|
||||||
setStore("activeMessage", messageId)
|
copyTabs(from: string, to: string) {
|
||||||
|
setStore("tabs", to, {
|
||||||
|
opened: store.tabs[from]?.opened ?? [],
|
||||||
|
})
|
||||||
},
|
},
|
||||||
clearActiveMessage() {
|
setActiveTab(tab: string | undefined) {
|
||||||
setStore("activeMessage", undefined)
|
setStore("tabs", store.active ?? "", "active", tab)
|
||||||
|
},
|
||||||
|
async open(tab: string) {
|
||||||
|
if (tab !== "chat") {
|
||||||
|
await file.open(tab)
|
||||||
|
}
|
||||||
|
if (!tabs()?.opened?.includes(tab)) {
|
||||||
|
setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
|
||||||
|
}
|
||||||
|
setStore("tabs", store.active ?? "", "active", tab)
|
||||||
|
},
|
||||||
|
close(tab: string) {
|
||||||
|
batch(() => {
|
||||||
|
if (!tabs()) return
|
||||||
|
setStore("tabs", store.active ?? "", {
|
||||||
|
active: tabs()!.active,
|
||||||
|
opened: tabs()!.opened.filter((x) => x !== tab),
|
||||||
|
})
|
||||||
|
if (tabs()!.active === tab) {
|
||||||
|
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||||
|
const previous = tabs()!.opened[Math.max(0, index - 1)]
|
||||||
|
setStore("tabs", store.active ?? "", "active", previous)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
move(tab: string, to: number) {
|
||||||
|
if (!tabs()) return
|
||||||
|
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore(
|
||||||
|
"tabs",
|
||||||
|
store.active ?? "",
|
||||||
|
"opened",
|
||||||
|
produce((opened) => {
|
||||||
|
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// setStore("node", path, "pinned", true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -611,9 +549,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
all() {
|
all() {
|
||||||
return store.items
|
return store.items
|
||||||
},
|
},
|
||||||
active() {
|
// active() {
|
||||||
return store.activeTab ? file.active() : undefined
|
// return store.activeTab ? file.active() : undefined
|
||||||
},
|
// },
|
||||||
addActive() {
|
addActive() {
|
||||||
setStore("activeTab", true)
|
setStore("activeTab", true)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import {
|
|||||||
Diff,
|
Diff,
|
||||||
Collapsible,
|
Collapsible,
|
||||||
DiffChanges,
|
DiffChanges,
|
||||||
ProgressCircle,
|
|
||||||
Message,
|
Message,
|
||||||
Typewriter,
|
Typewriter,
|
||||||
Card,
|
Card,
|
||||||
|
Code,
|
||||||
} from "@opencode-ai/ui"
|
} from "@opencode-ai/ui"
|
||||||
import { FileIcon } from "@/ui"
|
import { FileIcon } from "@/ui"
|
||||||
import FileTree from "@/components/file-tree"
|
import FileTree from "@/components/file-tree"
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
} from "@thisbeyond/solid-dnd"
|
} from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { Code } from "@/components/code"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||||
@@ -54,14 +53,6 @@ export default function Page() {
|
|||||||
let messageScrollElement!: HTMLDivElement
|
let messageScrollElement!: HTMLDivElement
|
||||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
// Set first message as active if none selected
|
|
||||||
const userMessages = local.session.userMessages()
|
|
||||||
if (userMessages.length > 0 && !local.session.activeMessage()) {
|
|
||||||
local.session.setActiveMessage(userMessages[0].id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -91,26 +82,26 @@ export default function Page() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.file.active()) {
|
// if (local.file.active()) {
|
||||||
const active = local.file.active()!
|
// const active = local.file.active()!
|
||||||
if (event.key === "Enter" && active.selection) {
|
// if (event.key === "Enter" && active.selection) {
|
||||||
local.context.add({
|
// local.context.add({
|
||||||
type: "file",
|
// type: "file",
|
||||||
path: active.path,
|
// path: active.path,
|
||||||
selection: { ...active.selection },
|
// selection: { ...active.selection },
|
||||||
})
|
// })
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (event.getModifierState(MOD)) {
|
// if (event.getModifierState(MOD)) {
|
||||||
if (event.key.toLowerCase() === "a") {
|
// if (event.key.toLowerCase() === "a") {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
if (event.key.toLowerCase() === "c") {
|
// if (event.key.toLowerCase() === "c") {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
@@ -140,21 +131,22 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateChange = (dir: 1 | -1) => {
|
// const navigateChange = (dir: 1 | -1) => {
|
||||||
const active = local.file.active()
|
// const active = local.file.active()
|
||||||
if (!active) return
|
// if (!active) return
|
||||||
const current = local.file.changeIndex(active.path)
|
// const current = local.file.changeIndex(active.path)
|
||||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
// const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||||
local.file.setChangeIndex(active.path, next)
|
// local.file.setChangeIndex(active.path, next)
|
||||||
}
|
// }
|
||||||
|
|
||||||
const handleTabChange = (path: string) => {
|
const handleTabChange = (path: string) => {
|
||||||
if (path === "chat" || path === "review") return
|
local.session.setActiveTab(path)
|
||||||
local.file.open(path)
|
if (path === "chat") return
|
||||||
|
local.session.open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabClose = (file: LocalFile) => {
|
const handleTabClose = (file: LocalFile) => {
|
||||||
local.file.close(file.path)
|
local.session.close(file.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
const handleDragStart = (event: unknown) => {
|
||||||
@@ -166,11 +158,11 @@ export default function Page() {
|
|||||||
const handleDragOver = (event: DragEvent) => {
|
const handleDragOver = (event: DragEvent) => {
|
||||||
const { draggable, droppable } = event
|
const { draggable, droppable } = event
|
||||||
if (draggable && droppable) {
|
if (draggable && droppable) {
|
||||||
const currentFiles = local.file.opened().map((file) => file.path)
|
const currentFiles = local.session.tabs()?.opened.map((file) => file)
|
||||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
const fromIndex = currentFiles?.indexOf(draggable.id.toString())
|
||||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
const toIndex = currentFiles?.indexOf(droppable.id.toString())
|
||||||
if (fromIndex !== toIndex) {
|
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||||
local.file.move(draggable.id.toString(), toIndex)
|
local.session.move(draggable.id.toString(), toIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,20 +171,20 @@ export default function Page() {
|
|||||||
setActiveItem(undefined)
|
setActiveItem(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollDiffItem = (element: HTMLElement) => {
|
// const scrollDiffItem = (element: HTMLElement) => {
|
||||||
element.scrollIntoView({ block: "start", behavior: "instant" })
|
// element.scrollIntoView({ block: "start", behavior: "instant" })
|
||||||
}
|
// }
|
||||||
|
|
||||||
const handleDiffTriggerClick = (event: MouseEvent) => {
|
const handleDiffTriggerClick = (event: MouseEvent) => {
|
||||||
// disabling scroll to diff for now
|
// disabling scroll to diff for now
|
||||||
return
|
return
|
||||||
const target = event.currentTarget as HTMLElement
|
// const target = event.currentTarget as HTMLElement
|
||||||
queueMicrotask(() => {
|
// queueMicrotask(() => {
|
||||||
if (target.getAttribute("aria-expanded") !== "true") return
|
// if (target.getAttribute("aria-expanded") !== "true") return
|
||||||
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
// const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
||||||
if (!item) return
|
// if (!item) return
|
||||||
scrollDiffItem(item)
|
// scrollDiffItem(item)
|
||||||
})
|
// })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||||
@@ -205,6 +197,10 @@ export default function Page() {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
local.session.setActive(session.id)
|
local.session.setActive(session.id)
|
||||||
|
if (!existingSession) {
|
||||||
|
local.session.copyTabs("", session.id)
|
||||||
|
}
|
||||||
|
local.session.setActiveTab(undefined)
|
||||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||||
|
|
||||||
const text = parts.map((part) => part.content).join("")
|
const text = parts.map((part) => part.content).join("")
|
||||||
@@ -427,20 +423,26 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
<ConstrainDragYAxis />
|
<ConstrainDragYAxis />
|
||||||
<Tabs onChange={handleTabChange}>
|
<Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||||
<div>Chat</div>
|
<div>Chat</div>
|
||||||
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
|
{/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
|
||||||
<ProgressCircle percentage={local.session.context() ?? 0} />
|
{/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
|
||||||
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
|
{/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
|
||||||
</Tooltip>
|
{/* </Tooltip> */}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
<SortableProvider ids={local.session.tabs()?.opened ?? []}>
|
||||||
<For each={local.file.opened()}>
|
<For each={local.session.tabs()?.opened ?? []}>
|
||||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
{(file) => (
|
||||||
|
<SortableTab
|
||||||
|
file={local.file.node(file)}
|
||||||
|
onTabClick={handleFileClick}
|
||||||
|
onTabClose={handleTabClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||||
@@ -452,64 +454,6 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
|
||||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
|
||||||
{(() => {
|
|
||||||
const activeFile = local.file.active()!
|
|
||||||
const view = local.file.view(activeFile.path)
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Show when={view !== "raw"}>
|
|
||||||
<div class="mr-1 flex items-center gap-1">
|
|
||||||
<Tooltip value="Previous change" placement="bottom">
|
|
||||||
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip value="Next change" placement="bottom">
|
|
||||||
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Tooltip value="Raw" placement="bottom">
|
|
||||||
<IconButton
|
|
||||||
icon="file-text"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{
|
|
||||||
"text-text": view === "raw",
|
|
||||||
"text-text-muted/70": view !== "raw",
|
|
||||||
"bg-background-element": view === "raw",
|
|
||||||
}}
|
|
||||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip value="Unified diff" placement="bottom">
|
|
||||||
<IconButton
|
|
||||||
icon="checklist"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{
|
|
||||||
"text-text": view === "diff-unified",
|
|
||||||
"text-text-muted/70": view !== "diff-unified",
|
|
||||||
"bg-background-element": view === "diff-unified",
|
|
||||||
}}
|
|
||||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip value="Split diff" placement="bottom">
|
|
||||||
<IconButton
|
|
||||||
icon="columns"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{
|
|
||||||
"text-text": view === "diff-split",
|
|
||||||
"text-text-muted/70": view !== "diff-split",
|
|
||||||
"bg-background-element": view === "diff-split",
|
|
||||||
}}
|
|
||||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||||
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||||
@@ -537,250 +481,289 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(activeSession) => (
|
{(session) => {
|
||||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
const [store, setStore] = createStore<{
|
||||||
<div class="flex-1 min-h-0">
|
messageId?: string
|
||||||
<Show when={local.session.userMessages().length > 1}>
|
}>()
|
||||||
<ul
|
|
||||||
role="list"
|
const messages = createMemo(() => sync.data.message[session().id] ?? [])
|
||||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
const userMessages = createMemo(() =>
|
||||||
>
|
messages()
|
||||||
<For each={local.session.userMessages()}>
|
.filter((m) => m.role === "user")
|
||||||
|
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||||
|
)
|
||||||
|
const lastUserMessage = createMemo(() => {
|
||||||
|
return userMessages()?.at(0)
|
||||||
|
})
|
||||||
|
const activeMessage = createMemo(() => {
|
||||||
|
if (!store.messageId) return lastUserMessage()
|
||||||
|
return userMessages()?.find((m) => m.id === store.messageId)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<Show when={userMessages().length > 1}>
|
||||||
|
<ul
|
||||||
|
role="list"
|
||||||
|
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||||
|
>
|
||||||
|
<For each={userMessages()}>
|
||||||
|
{(message) => {
|
||||||
|
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||||
|
const assistantMessages = createMemo(() => {
|
||||||
|
return sync.data.message[session().id]?.filter(
|
||||||
|
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||||
|
) as AssistantMessageType[]
|
||||||
|
})
|
||||||
|
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||||
|
const working = createMemo(() => !message.summary?.body && !error())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class="group/li flex items-center self-stretch">
|
||||||
|
<button
|
||||||
|
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
||||||
|
onClick={() => setStore("messageId", message.id)}
|
||||||
|
>
|
||||||
|
<Switch>
|
||||||
|
<Match when={working()}>
|
||||||
|
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<DiffChanges diff={diffs()} variant="bars" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<div
|
||||||
|
data-active={activeMessage()?.id === message.id}
|
||||||
|
classList={{
|
||||||
|
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||||
|
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={message.summary?.title} fallback="New message">
|
||||||
|
{message.summary?.title}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</Show>
|
||||||
|
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||||
|
<For each={userMessages()}>
|
||||||
{(message) => {
|
{(message) => {
|
||||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
const isActive = createMemo(() => activeMessage()?.id === message.id)
|
||||||
|
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||||
const assistantMessages = createMemo(() => {
|
const assistantMessages = createMemo(() => {
|
||||||
return sync.data.message[activeSession().id]?.filter(
|
return sync.data.message[session().id]?.filter(
|
||||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||||
) as AssistantMessageType[]
|
) as AssistantMessageType[]
|
||||||
})
|
})
|
||||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||||
const working = createMemo(() => !message.summary?.body && !error())
|
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
||||||
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
|
const parts = createMemo(() => sync.data.part[message.id])
|
||||||
|
const title = createMemo(() => message.summary?.title)
|
||||||
|
const summary = createMemo(() => message.summary?.body)
|
||||||
|
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||||
|
const hasToolPart = createMemo(() =>
|
||||||
|
assistantMessages()
|
||||||
|
?.flatMap((m) => sync.data.part[m.id])
|
||||||
|
.some((p) => p?.type === "tool"),
|
||||||
|
)
|
||||||
|
const working = createMemo(() => !summary() && !error())
|
||||||
|
|
||||||
|
// allowing time for the animations to finish
|
||||||
|
createEffect(() => {
|
||||||
|
title()
|
||||||
|
setTimeout(() => setTitled(!!title()), 10_000)
|
||||||
|
})
|
||||||
|
createEffect(() => {
|
||||||
|
const complete = !!summary() || !!error()
|
||||||
|
setTimeout(() => setCompleted(complete), 1200)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li class="group/li flex items-center self-stretch">
|
<Show when={isActive()}>
|
||||||
<button
|
<div
|
||||||
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
data-message={message.id}
|
||||||
onClick={() => local.session.setActiveMessage(message.id)}
|
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
||||||
>
|
>
|
||||||
<Switch>
|
{/* Title */}
|
||||||
<Match when={working()}>
|
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
<div class="w-full text-14-medium text-text-strong">
|
||||||
</Match>
|
<Show
|
||||||
<Match when={true}>
|
when={titled()}
|
||||||
<DiffChanges diff={diffs()} variant="bars" />
|
fallback={
|
||||||
</Match>
|
<Typewriter
|
||||||
</Switch>
|
as="h1"
|
||||||
<div
|
text={title()}
|
||||||
data-active={local.session.activeMessage()?.id === message.id}
|
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||||
classList={{
|
/>
|
||||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
}
|
||||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
>
|
||||||
}}
|
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
||||||
>
|
{title()}
|
||||||
<Show when={message.summary?.title} fallback="New message">
|
</h1>
|
||||||
{message.summary?.title}
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<div class="-mt-8">
|
||||||
</li>
|
<Message message={message} parts={parts()} />
|
||||||
|
</div>
|
||||||
|
{/* Summary */}
|
||||||
|
<Show when={completed()}>
|
||||||
|
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||||
|
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||||
|
<h2 class="text-12-medium text-text-weak">
|
||||||
|
<Switch>
|
||||||
|
<Match when={diffs().length}>Summary</Match>
|
||||||
|
<Match when={true}>Response</Match>
|
||||||
|
</Switch>
|
||||||
|
</h2>
|
||||||
|
<Show when={summary()}>
|
||||||
|
{(summary) => (
|
||||||
|
<Markdown
|
||||||
|
classList={{ "[&>*]:fade-up-text": !diffs().length }}
|
||||||
|
text={summary()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Accordion class="w-full" multiple>
|
||||||
|
<For each={diffs()}>
|
||||||
|
{(diff) => (
|
||||||
|
<Accordion.Item value={diff.file}>
|
||||||
|
<Accordion.Header>
|
||||||
|
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
||||||
|
<div class="flex items-center justify-between w-full gap-5">
|
||||||
|
<div class="grow flex items-center gap-5 min-w-0">
|
||||||
|
<FileIcon
|
||||||
|
node={{ path: diff.file, type: "file" }}
|
||||||
|
class="shrink-0 size-4"
|
||||||
|
/>
|
||||||
|
<div class="flex grow min-w-0">
|
||||||
|
<Show when={diff.file.includes("/")}>
|
||||||
|
<span class="text-text-base truncate-start">
|
||||||
|
{getDirectory(diff.file)}‎
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="text-text-strong shrink-0">
|
||||||
|
{getFilename(diff.file)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||||
|
<DiffChanges diff={diff} />
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content>
|
||||||
|
<Diff
|
||||||
|
before={{
|
||||||
|
name: diff.file!,
|
||||||
|
contents: diff.before!,
|
||||||
|
}}
|
||||||
|
after={{
|
||||||
|
name: diff.file!,
|
||||||
|
contents: diff.after!,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={error() && !expanded()}>
|
||||||
|
<Card variant="error" class="text-text-on-critical-base">
|
||||||
|
{error()?.data?.message as string}
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
|
{/* Response */}
|
||||||
|
<div class="w-full">
|
||||||
|
<Switch>
|
||||||
|
<Match when={!completed()}>
|
||||||
|
<MessageProgress
|
||||||
|
assistantMessages={assistantMessages}
|
||||||
|
done={!working()}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={completed() && hasToolPart()}>
|
||||||
|
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||||
|
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||||
|
<div class="flex items-center gap-1 self-stretch">
|
||||||
|
<div class="text-12-medium">
|
||||||
|
<Switch>
|
||||||
|
<Match when={expanded()}>Hide details</Match>
|
||||||
|
<Match when={!expanded()}>Show details</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
<Collapsible.Arrow />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||||
|
<For each={assistantMessages()}>
|
||||||
|
{(assistantMessage) => {
|
||||||
|
const parts = createMemo(
|
||||||
|
() => sync.data.part[assistantMessage.id],
|
||||||
|
)
|
||||||
|
return <Message message={assistantMessage} parts={parts()} />
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={error()}>
|
||||||
|
<Card variant="error" class="text-text-on-critical-base">
|
||||||
|
{error()?.data?.message as string}
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</ul>
|
</div>
|
||||||
</Show>
|
|
||||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
|
||||||
<For each={local.session.userMessages()}>
|
|
||||||
{(message) => {
|
|
||||||
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
|
|
||||||
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
|
||||||
const assistantMessages = createMemo(() => {
|
|
||||||
return sync.data.message[activeSession().id]?.filter(
|
|
||||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
|
||||||
) as AssistantMessageType[]
|
|
||||||
})
|
|
||||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
|
||||||
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
|
||||||
const parts = createMemo(() => sync.data.part[message.id])
|
|
||||||
const title = createMemo(() => message.summary?.title)
|
|
||||||
const summary = createMemo(() => message.summary?.body)
|
|
||||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
|
||||||
const hasToolPart = createMemo(() =>
|
|
||||||
assistantMessages()
|
|
||||||
?.flatMap((m) => sync.data.part[m.id])
|
|
||||||
.some((p) => p?.type === "tool"),
|
|
||||||
)
|
|
||||||
const working = createMemo(() => !summary() && !error())
|
|
||||||
|
|
||||||
// allowing time for the animations to finish
|
|
||||||
createEffect(() => {
|
|
||||||
title()
|
|
||||||
setTimeout(() => setTitled(!!title()), 10_000)
|
|
||||||
})
|
|
||||||
createEffect(() => {
|
|
||||||
const complete = !!summary() || !!error()
|
|
||||||
setTimeout(() => setCompleted(complete), 1200)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={isActive()}>
|
|
||||||
<div
|
|
||||||
data-message={message.id}
|
|
||||||
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
|
||||||
<div class="w-full text-14-medium text-text-strong">
|
|
||||||
<Show
|
|
||||||
when={titled()}
|
|
||||||
fallback={
|
|
||||||
<Typewriter
|
|
||||||
as="h1"
|
|
||||||
text={title()}
|
|
||||||
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="-mt-8">
|
|
||||||
<Message message={message} parts={parts()} />
|
|
||||||
</div>
|
|
||||||
{/* Summary */}
|
|
||||||
<Show when={completed()}>
|
|
||||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
|
||||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
|
||||||
<h2 class="text-12-medium text-text-weak">
|
|
||||||
<Switch>
|
|
||||||
<Match when={diffs().length}>Summary</Match>
|
|
||||||
<Match when={true}>Response</Match>
|
|
||||||
</Switch>
|
|
||||||
</h2>
|
|
||||||
<Show when={summary()}>
|
|
||||||
{(summary) => (
|
|
||||||
<Markdown
|
|
||||||
classList={{ "[&>*]:fade-up-text": !diffs().length }}
|
|
||||||
text={summary()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Accordion class="w-full" multiple>
|
|
||||||
<For each={diffs()}>
|
|
||||||
{(diff) => (
|
|
||||||
<Accordion.Item value={diff.file}>
|
|
||||||
<Accordion.Header>
|
|
||||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
|
||||||
<div class="flex items-center justify-between w-full gap-5">
|
|
||||||
<div class="grow flex items-center gap-5 min-w-0">
|
|
||||||
<FileIcon
|
|
||||||
node={{ path: diff.file, type: "file" }}
|
|
||||||
class="shrink-0 size-4"
|
|
||||||
/>
|
|
||||||
<div class="flex grow min-w-0">
|
|
||||||
<Show when={diff.file.includes("/")}>
|
|
||||||
<span class="text-text-base truncate-start">
|
|
||||||
{getDirectory(diff.file)}‎
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-text-strong shrink-0">
|
|
||||||
{getFilename(diff.file)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
|
||||||
<DiffChanges diff={diff} />
|
|
||||||
<Icon name="chevron-grabber-vertical" size="small" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</Accordion.Header>
|
|
||||||
<Accordion.Content>
|
|
||||||
<Diff
|
|
||||||
before={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.before!,
|
|
||||||
}}
|
|
||||||
after={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.after!,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={error() && !expanded()}>
|
|
||||||
<Card variant="error" class="text-text-on-critical-base">
|
|
||||||
{error()?.data?.message as string}
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
{/* Response */}
|
|
||||||
<div class="w-full">
|
|
||||||
<Switch>
|
|
||||||
<Match when={!completed()}>
|
|
||||||
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
|
|
||||||
</Match>
|
|
||||||
<Match when={completed() && hasToolPart()}>
|
|
||||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
|
||||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
|
||||||
<div class="flex items-center gap-1 self-stretch">
|
|
||||||
<div class="text-12-medium">
|
|
||||||
<Switch>
|
|
||||||
<Match when={expanded()}>Hide details</Match>
|
|
||||||
<Match when={!expanded()}>Show details</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<Collapsible.Arrow />
|
|
||||||
</div>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<Collapsible.Content>
|
|
||||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
|
||||||
<For each={assistantMessages()}>
|
|
||||||
{(assistantMessage) => {
|
|
||||||
const parts = createMemo(
|
|
||||||
() => sync.data.part[assistantMessage.id],
|
|
||||||
)
|
|
||||||
return <Message message={assistantMessage} parts={parts()} />
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={error()}>
|
|
||||||
<Card variant="error" class="text-text-on-critical-base">
|
|
||||||
{error()?.data?.message as string}
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||||
<For each={local.file.opened()}>
|
<For each={local.session.tabs()?.opened}>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Tabs.Content value={file.path} class="select-text">
|
<Tabs.Content value={file} class="select-text">
|
||||||
{(() => {
|
{(() => {
|
||||||
const view = local.file.view(file.path)
|
{
|
||||||
const showRaw = view === "raw" || !file.content?.diff
|
/* const view = local.file.view(file) */
|
||||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
}
|
||||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
{
|
||||||
|
/* const showRaw = view === "raw" || !file.content?.diff */
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
|
||||||
|
}
|
||||||
|
const node = local.file.node(file)
|
||||||
|
return (
|
||||||
|
<Code
|
||||||
|
file={{ name: node.path, contents: node.content?.content ?? "" }}
|
||||||
|
disableFileHeader
|
||||||
|
overflow="scroll"
|
||||||
|
class="pt-3 pb-40"
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)}
|
)}
|
||||||
@@ -847,7 +830,7 @@ export default function Page() {
|
|||||||
items={local.file.search}
|
items={local.file.search}
|
||||||
key={(x) => x}
|
key={(x) => x}
|
||||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
onSelect={(x) => (x ? local.session.open(x) : undefined)}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
3
packages/ui/src/components/code.css
Normal file
3
packages/ui/src/components/code.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[data-component="code"] {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
52
packages/ui/src/components/code.tsx
Normal file
52
packages/ui/src/components/code.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
|
||||||
|
import { ComponentProps, createEffect, splitProps } from "solid-js"
|
||||||
|
|
||||||
|
export type CodeProps<T = {}> = FileOptions<T> & {
|
||||||
|
file: FileContents
|
||||||
|
annotations?: LineAnnotation<T>[]
|
||||||
|
class?: string
|
||||||
|
classList?: ComponentProps<"div">["classList"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code<T>(props: CodeProps<T>) {
|
||||||
|
let container!: HTMLDivElement
|
||||||
|
const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"])
|
||||||
|
const file = () => local.file
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const instance = new File<T>({
|
||||||
|
theme: { dark: "oc-1-dark", light: "oc-1-light" }, // or any Shiki theme
|
||||||
|
overflow: "wrap", // or 'scroll'
|
||||||
|
themeType: "system", // 'system', 'light', or 'dark'
|
||||||
|
disableLineNumbers: false, // optional
|
||||||
|
// lang: 'typescript', // optional - auto-detected from filename if not provided
|
||||||
|
...others,
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.render({
|
||||||
|
file: file(),
|
||||||
|
lineAnnotations: local.annotations,
|
||||||
|
containerWrapper: container,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-component="code"
|
||||||
|
style={{
|
||||||
|
"--pjs-font-family": "var(--font-family-mono)",
|
||||||
|
"--pjs-font-size": "var(--font-size-small)",
|
||||||
|
"--pjs-line-height": "24px",
|
||||||
|
"--pjs-tab-size": 2,
|
||||||
|
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
|
||||||
|
"--pjs-header-font-family": "var(--font-family-sans)",
|
||||||
|
"--pjs-gap-block": 0,
|
||||||
|
}}
|
||||||
|
classList={{
|
||||||
|
...(local.classList || {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
}}
|
||||||
|
ref={container}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ export * from "./accordion"
|
|||||||
export * from "./button"
|
export * from "./button"
|
||||||
export * from "./card"
|
export * from "./card"
|
||||||
export * from "./checkbox"
|
export * from "./checkbox"
|
||||||
|
export * from "./code"
|
||||||
export * from "./collapsible"
|
export * from "./collapsible"
|
||||||
export * from "./dialog"
|
export * from "./dialog"
|
||||||
export * from "./diff"
|
export * from "./diff"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
@import "../components/button.css" layer(components);
|
@import "../components/button.css" layer(components);
|
||||||
@import "../components/card.css" layer(components);
|
@import "../components/card.css" layer(components);
|
||||||
@import "../components/checkbox.css" layer(components);
|
@import "../components/checkbox.css" layer(components);
|
||||||
|
@import "../components/code.css" layer(components);
|
||||||
@import "../components/diff.css" layer(components);
|
@import "../components/diff.css" layer(components);
|
||||||
@import "../components/diff-changes.css" layer(components);
|
@import "../components/diff-changes.css" layer(components);
|
||||||
@import "../components/collapsible.css" layer(components);
|
@import "../components/collapsible.css" layer(components);
|
||||||
|
|||||||
Reference in New Issue
Block a user