Merge branch 'dev' into feat/fff-search-tools
This commit is contained in:
@@ -285,7 +285,7 @@
|
||||
[data-component="markdown"] {
|
||||
margin-top: 24px;
|
||||
font-style: normal;
|
||||
font-size: inherit;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-weak);
|
||||
|
||||
strong,
|
||||
|
||||
@@ -310,11 +310,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
||||
icon: "checklist",
|
||||
title: i18n.t("ui.tool.todos"),
|
||||
}
|
||||
case "todoread":
|
||||
return {
|
||||
icon: "checklist",
|
||||
title: i18n.t("ui.tool.todos.read"),
|
||||
}
|
||||
case "question":
|
||||
return {
|
||||
icon: "bubble-5",
|
||||
@@ -357,7 +352,7 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) =
|
||||
}
|
||||
|
||||
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
||||
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
|
||||
const HIDDEN_TOOLS = new Set(["todowrite"])
|
||||
|
||||
function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
if (Array.isArray(value)) return value
|
||||
@@ -1210,7 +1205,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const part = () => props.part as ToolPart
|
||||
if (part().tool === "todowrite" || part().tool === "todoread") return null
|
||||
if (part().tool === "todowrite") return null
|
||||
|
||||
const hideQuestion = createMemo(
|
||||
() => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"),
|
||||
|
||||
@@ -151,7 +151,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
const open = () => props.open ?? store.open
|
||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
@@ -282,11 +281,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<div class="pb-6">
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
<For each={props.diffs}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
|
||||
const item = createMemo(() => diffs().get(file)!)
|
||||
const file = diff.file
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const force = () => !!store.force[file]
|
||||
@@ -294,9 +292,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||
const afterText = () => (typeof item().after === "string" ? item().after : "")
|
||||
const changedLines = () => item().additions + item().deletions
|
||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||
const changedLines = () => diff.additions + diff.deletions
|
||||
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
||||
|
||||
const tooLarge = createMemo(() => {
|
||||
@@ -307,9 +305,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
const isAdded = () =>
|
||||
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
const isDeleted = () =>
|
||||
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
@@ -346,7 +344,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(item(), selection),
|
||||
preview: selectionPreview(diff, selection),
|
||||
})
|
||||
},
|
||||
onUpdate: ({ id, comment, selection }) => {
|
||||
@@ -355,7 +353,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(item(), selection),
|
||||
preview: selectionPreview(diff, selection),
|
||||
})
|
||||
},
|
||||
onDelete: (comment) => {
|
||||
@@ -432,7 +430,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
<DiffChanges changes={item()} />
|
||||
<DiffChanges changes={diff} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
@@ -446,7 +444,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={item()} />
|
||||
<DiffChanges changes={diff} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
@@ -492,7 +490,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
preloadedDiff={item().preloaded}
|
||||
preloadedDiff={diff.preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
@@ -509,17 +507,17 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
name: file,
|
||||
contents: typeof item().before === "string" ? item().before : "",
|
||||
contents: typeof diff.before === "string" ? diff.before : "",
|
||||
}}
|
||||
after={{
|
||||
name: file,
|
||||
contents: typeof item().after === "string" ? item().after : "",
|
||||
contents: typeof diff.after === "string" ? diff.after : "",
|
||||
}}
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: file,
|
||||
before: item().before,
|
||||
after: item().after,
|
||||
before: diff.before,
|
||||
after: diff.after,
|
||||
readFile: props.readFile,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -85,7 +85,7 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const hidden = new Set(["todowrite", "todoread"])
|
||||
const hidden = new Set(["todowrite"])
|
||||
|
||||
function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||
if (part.type === "tool") {
|
||||
|
||||
@@ -425,13 +425,60 @@ const TOOL_SAMPLES = {
|
||||
// Fake data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
const SESSION_ID = "playground-session"
|
||||
const DEFAULT_SESSION = { id: SESSION_ID, title: "Timeline Playground" }
|
||||
|
||||
function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } {
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalize(raw: unknown) {
|
||||
if (Array.isArray(raw)) {
|
||||
const info = raw.find((row) => record(row) && row.type === "session" && record(row.data))?.data
|
||||
if (!record(info) || typeof info.id !== "string") {
|
||||
throw new Error("No session found in JSON")
|
||||
}
|
||||
|
||||
const part = new Map<string, Part[]>()
|
||||
const messages = raw.flatMap((row) => {
|
||||
if (!record(row) || !record(row.data)) return []
|
||||
if (row.type === "part" && typeof row.data.messageID === "string") {
|
||||
const list = part.get(row.data.messageID) ?? []
|
||||
list.push(row.data as Part)
|
||||
part.set(row.data.messageID, list)
|
||||
return []
|
||||
}
|
||||
if (row.type !== "message" || typeof row.data.id !== "string") return []
|
||||
return [{ info: row.data as Message, parts: [] as Part[] }]
|
||||
})
|
||||
|
||||
return {
|
||||
info,
|
||||
messages: messages.map((msg) => ({
|
||||
info: msg.info,
|
||||
parts: part.get(msg.info.id) ?? [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
if (!record(raw) || !record(raw.info) || typeof raw.info.id !== "string" || !Array.isArray(raw.messages)) {
|
||||
throw new Error("Expected an `opencode export` JSON file")
|
||||
}
|
||||
|
||||
return {
|
||||
info: raw.info,
|
||||
messages: raw.messages.flatMap((row) => {
|
||||
if (!record(row) || !record(row.info) || typeof row.info.id !== "string") return []
|
||||
return [{ info: row.info as Message, parts: Array.isArray(row.parts) ? (row.parts as Part[]) : [] }]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function mkUser(text: string, extra: Part[] = [], sessionID = SESSION_ID): { message: UserMessage; parts: Part[] } {
|
||||
const id = uid()
|
||||
return {
|
||||
message: {
|
||||
id,
|
||||
sessionID: SESSION_ID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "code",
|
||||
@@ -445,10 +492,10 @@ function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts
|
||||
}
|
||||
}
|
||||
|
||||
function mkAssistant(parentID: string): AssistantMessage {
|
||||
function mkAssistant(parentID: string, sessionID = SESSION_ID): AssistantMessage {
|
||||
return {
|
||||
id: uid(),
|
||||
sessionID: SESSION_ID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: Date.now(), completed: Date.now() + 3000 },
|
||||
parentID,
|
||||
@@ -932,6 +979,20 @@ const CSS_CONTROLS: CSSControl[] = [
|
||||
},
|
||||
|
||||
// --- Reasoning part ---
|
||||
{
|
||||
key: "reasoning-md-font-size",
|
||||
label: "Reasoning font size",
|
||||
group: "Reasoning Part",
|
||||
type: "range",
|
||||
initial: "14",
|
||||
selector: '[data-component="reasoning-part"] [data-component="markdown"]',
|
||||
property: "font-size",
|
||||
min: "10",
|
||||
max: "22",
|
||||
step: "1",
|
||||
unit: "px",
|
||||
source: { file: MP, anchor: '[data-component="reasoning-part"]', prop: "font-size", format: px },
|
||||
},
|
||||
{
|
||||
key: "reasoning-md-margin-top",
|
||||
label: "Reasoning markdown margin-top",
|
||||
@@ -1010,12 +1071,24 @@ function Playground() {
|
||||
messages: [],
|
||||
parts: {},
|
||||
})
|
||||
const [session, setSession] = createSignal({ ...DEFAULT_SESSION })
|
||||
const [loaded, setLoaded] = createSignal("")
|
||||
const [issue, setIssue] = createSignal("")
|
||||
|
||||
// ---- CSS overrides ----
|
||||
const [css, setCss] = createStore<Record<string, string>>({})
|
||||
const [defaults, setDefaults] = createStore<Record<string, string>>({})
|
||||
let styleEl: HTMLStyleElement | undefined
|
||||
let previewRef: HTMLDivElement | undefined
|
||||
let pick: HTMLInputElement | undefined
|
||||
|
||||
const sample = (ctrl: CSSControl) => {
|
||||
if (!ctrl.group.startsWith("Markdown")) return ctrl.selector
|
||||
return ctrl.selector.replace(
|
||||
'[data-component="markdown"]',
|
||||
'[data-component="text-part"] [data-component="markdown"]',
|
||||
)
|
||||
}
|
||||
|
||||
/** Read computed styles from the DOM to seed slider defaults */
|
||||
const readDefaults = () => {
|
||||
@@ -1023,7 +1096,7 @@ function Playground() {
|
||||
if (!root) return
|
||||
const next: Record<string, string> = {}
|
||||
for (const ctrl of CSS_CONTROLS) {
|
||||
const el = root.querySelector(ctrl.selector) as HTMLElement | null
|
||||
const el = (root.querySelector(sample(ctrl)) ?? root.querySelector(ctrl.selector)) as HTMLElement | null
|
||||
if (!el) continue
|
||||
const styles = getComputedStyle(el)
|
||||
// Use bracket access — getPropertyValue doesn't resolve shorthands
|
||||
@@ -1074,10 +1147,10 @@ function Playground() {
|
||||
const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
|
||||
|
||||
const data = createMemo(() => ({
|
||||
session: [{ id: SESSION_ID }],
|
||||
session: [session()],
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
message: { [SESSION_ID]: state.messages },
|
||||
message: { [session().id]: state.messages },
|
||||
part: state.parts,
|
||||
provider: {
|
||||
all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
|
||||
@@ -1109,8 +1182,8 @@ function Playground() {
|
||||
const id = lastAssistantID()
|
||||
if (id) return id
|
||||
// Create a minimal placeholder turn
|
||||
const user = mkUser("...")
|
||||
const asst = mkAssistant(user.message.id)
|
||||
const user = mkUser("...", [], session().id)
|
||||
const asst = mkAssistant(user.message.id, session().id)
|
||||
setState(
|
||||
produce((draft) => {
|
||||
draft.messages.push(user.message)
|
||||
@@ -1136,8 +1209,8 @@ function Playground() {
|
||||
// ---- User message helpers ----
|
||||
const addUser = (variant: keyof typeof USER_VARIANTS) => {
|
||||
const v = USER_VARIANTS[variant]
|
||||
const user = mkUser(v.text, v.parts)
|
||||
const asst = mkAssistant(user.message.id)
|
||||
const user = mkUser(v.text, v.parts, session().id)
|
||||
const asst = mkAssistant(user.message.id, session().id)
|
||||
setState(
|
||||
produce((draft) => {
|
||||
draft.messages.push(user.message)
|
||||
@@ -1164,8 +1237,8 @@ function Playground() {
|
||||
|
||||
// ---- Composite helpers (create full turns with user + assistant) ----
|
||||
const addFullTurn = (userText: string, parts: Part[]) => {
|
||||
const user = mkUser(userText)
|
||||
const asst = mkAssistant(user.message.id)
|
||||
const user = mkUser(userText, [], session().id)
|
||||
const asst = mkAssistant(user.message.id, session().id)
|
||||
setState(
|
||||
produce((draft) => {
|
||||
draft.messages.push(user.message)
|
||||
@@ -1222,9 +1295,91 @@ function Playground() {
|
||||
addReasoningFullTurn()
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
const user = userMessages().at(-1)
|
||||
if (!user) return
|
||||
const now = Date.now()
|
||||
|
||||
setState(
|
||||
produce((draft) => {
|
||||
const msg = draft.messages.findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && item.parentID === user.id,
|
||||
)
|
||||
|
||||
if (msg) {
|
||||
const time = msg.time ?? { created: now }
|
||||
msg.time = { ...time, completed: time.completed ?? now }
|
||||
msg.error = { name: "MessageAbortedError", message: "Interrupted" }
|
||||
return
|
||||
}
|
||||
|
||||
const asst = mkAssistant(user.id, session().id)
|
||||
asst.time = { created: now, completed: now }
|
||||
asst.error = { name: "MessageAbortedError", message: "Interrupted" }
|
||||
draft.messages.push(asst)
|
||||
draft.parts[asst.id] = []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const load = (raw: unknown, name: string) => {
|
||||
const next = normalize(raw)
|
||||
const id = typeof next.info.id === "string" && next.info.id ? next.info.id : SESSION_ID
|
||||
const messages = next.messages.map((msg) => ({
|
||||
...msg.info,
|
||||
sessionID: typeof msg.info.sessionID === "string" ? msg.info.sessionID : id,
|
||||
}))
|
||||
const parts = Object.fromEntries(
|
||||
next.messages.map((msg, idx) => {
|
||||
const info = messages[idx]
|
||||
return [
|
||||
info.id,
|
||||
msg.parts.map((part) => ({
|
||||
...part,
|
||||
messageID: typeof part.messageID === "string" ? part.messageID : info.id,
|
||||
sessionID: typeof part.sessionID === "string" ? part.sessionID : info.sessionID,
|
||||
})),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
batch(() => {
|
||||
setSession({
|
||||
...DEFAULT_SESSION,
|
||||
...next.info,
|
||||
id,
|
||||
title: typeof next.info.title === "string" && next.info.title ? next.info.title : name,
|
||||
})
|
||||
setState({ messages, parts })
|
||||
setLoaded(name)
|
||||
setIssue("")
|
||||
})
|
||||
}
|
||||
|
||||
const importFile = async (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIssue("")
|
||||
|
||||
try {
|
||||
load(JSON.parse(await file.text()), file.name)
|
||||
} catch (err) {
|
||||
setIssue(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
input.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
setState({ messages: [], parts: {} })
|
||||
seq = 0
|
||||
batch(() => {
|
||||
setState({ messages: [], parts: {} })
|
||||
setSession({ ...DEFAULT_SESSION })
|
||||
setLoaded("")
|
||||
setIssue("")
|
||||
seq = 0
|
||||
})
|
||||
}
|
||||
|
||||
// ---- CSS export ----
|
||||
@@ -1292,9 +1447,14 @@ function Playground() {
|
||||
}
|
||||
setApplyResult(lines.join("\n"))
|
||||
|
||||
if (ok > 0) {
|
||||
// Clear overrides — values are now in source CSS, Vite will HMR.
|
||||
resetCss()
|
||||
if (ok === edits.length) {
|
||||
batch(() => {
|
||||
for (const ctrl of controls) {
|
||||
setDefaults(ctrl.key, css[ctrl.key]!)
|
||||
setCss(ctrl.key, undefined as any)
|
||||
}
|
||||
})
|
||||
updateStyle()
|
||||
// Wait for Vite HMR then re-read computed defaults
|
||||
setTimeout(readDefaults, 500)
|
||||
}
|
||||
@@ -1393,6 +1553,35 @@ function Playground() {
|
||||
</button>
|
||||
<Show when={panels.generators}>
|
||||
<div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
|
||||
{/* ---- Session import ---- */}
|
||||
<div style={sectionLabel}>Import session</div>
|
||||
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
|
||||
Replaces the current timeline with an `opencode export` JSON file
|
||||
</div>
|
||||
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
|
||||
<button style={btnAccent} onClick={() => pick?.click()}>
|
||||
Import session
|
||||
</button>
|
||||
<input
|
||||
ref={pick!}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={importFile}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
<Show when={loaded()}>
|
||||
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "line-height": "1.4" }}>
|
||||
{loaded()} • {session().title || session().id} • {state.messages.length} message
|
||||
{state.messages.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={issue()}>
|
||||
<div style={{ "font-size": "10px", color: "var(--text-on-critical-base)", "line-height": "1.4" }}>
|
||||
{issue()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ---- User messages ---- */}
|
||||
<div style={sectionLabel}>User messages</div>
|
||||
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
|
||||
@@ -1407,6 +1596,19 @@ function Playground() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
|
||||
<button
|
||||
style={{
|
||||
...btnDanger,
|
||||
opacity: userMessages().length === 0 ? "0.5" : "1",
|
||||
cursor: userMessages().length === 0 ? "not-allowed" : "pointer",
|
||||
}}
|
||||
disabled={userMessages().length === 0}
|
||||
onClick={interrupt}
|
||||
>
|
||||
Interrupt last
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ---- Text and reasoning blocks ---- */}
|
||||
<div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
|
||||
@@ -1716,7 +1918,7 @@ function Playground() {
|
||||
"font-size": "14px",
|
||||
}}
|
||||
>
|
||||
Click a generator button to add messages
|
||||
Click a generator button or import a session
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1729,7 +1931,7 @@ function Playground() {
|
||||
{(msg) => (
|
||||
<div style={{ width: "100%" }}>
|
||||
<SessionTurn
|
||||
sessionID={SESSION_ID}
|
||||
sessionID={session().id}
|
||||
messageID={msg.id}
|
||||
messages={state.messages}
|
||||
active={false}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict: Record<string, string> = {
|
||||
"ui.sessionReview.title": "Session changes",
|
||||
"ui.sessionReview.title.git": "Git changes",
|
||||
"ui.sessionReview.title.branch": "Branch changes",
|
||||
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||
"ui.sessionReview.diffStyle.split": "Split",
|
||||
|
||||
Reference in New Issue
Block a user