refactor(tui): simplify quark migration after review
- fix permission input losing reactivity (raw slot read; regression test) - BackgroundToolHint tracks structure + tool slots instead of whole-collection values - group views share mapArray-backed usePartSlots (per-ref node reuse) - dedupe streaming handlers behind variant-scoped modify/insert helpers - part ID scheme has one definition; hasText required in message navigation - expose read-only PartsView to view components; misc cleanups
This commit is contained in:
parent
41a24a9973
commit
4027dbb4d8
11 changed files with 379 additions and 196 deletions
|
|
@ -7,6 +7,10 @@ const messages: SessionMessageInfo[] = [
|
|||
assistant("assistant-1", "Response"),
|
||||
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
|
||||
]
|
||||
const hasText = (messageID: string) => {
|
||||
const message = messages.find((item) => item.id === messageID)
|
||||
return message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim())
|
||||
}
|
||||
const children = [
|
||||
{ id: "user-1", y: 0 },
|
||||
{ id: "assistant-1", y: 20 },
|
||||
|
|
@ -24,6 +28,7 @@ test("finds the next user message without stopping at an assistant message", ()
|
|||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
userOnly: true,
|
||||
|
|
@ -37,6 +42,7 @@ test("finds the previous user message without stopping at an assistant message",
|
|||
direction: "prev",
|
||||
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 35,
|
||||
viewportY: 0,
|
||||
userOnly: true,
|
||||
|
|
@ -50,6 +56,7 @@ test("preserves navigation across both user and assistant messages", () => {
|
|||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
}),
|
||||
|
|
@ -59,6 +66,7 @@ test("preserves navigation across both user and assistant messages", () => {
|
|||
direction: "prev",
|
||||
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 35,
|
||||
viewportY: 0,
|
||||
}),
|
||||
|
|
@ -71,6 +79,7 @@ test("uses the selected message when the viewport is too tall to scroll", () =>
|
|||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
|
|
@ -82,6 +91,7 @@ test("uses the selected message when the viewport is too tall to scroll", () =>
|
|||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
|
|
@ -96,6 +106,7 @@ test("stops at the first and last selected user message", () => {
|
|||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
|
|
@ -107,6 +118,7 @@ test("stops at the first and last selected user message", () => {
|
|||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
|
|
@ -121,6 +133,7 @@ test("keeps the logical boundary when layout temporarily moves it outside the vi
|
|||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
|
|
@ -135,6 +148,7 @@ test("stops at the first and last message", () => {
|
|||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
|
|
@ -145,6 +159,7 @@ test("stops at the first and last message", () => {
|
|||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
|
|
|
|||
115
packages/tui/test/cli/tui/permission.test.tsx
Normal file
115
packages/tui/test/cli/tui/permission.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client"
|
||||
import { createEffect, type ParentProps } from "solid-js"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { usePermissionInput } from "../../../src/routes/session/permission"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function SyncLocation() {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
createEffect(() => location.set(data.location.default()))
|
||||
return null
|
||||
}
|
||||
|
||||
function DataProvider(props: ParentProps) {
|
||||
return (
|
||||
<DataProviderBase>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
</DataProviderBase>
|
||||
)
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
test("permission input tracks the tool part slot as input settles", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-permission-input"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
let input!: () => unknown
|
||||
|
||||
const request = {
|
||||
id: "perm_1",
|
||||
sessionID,
|
||||
permission: "shell",
|
||||
resources: [],
|
||||
metadata: {},
|
||||
source: { messageID: "message-assistant", callID: "call-permission" },
|
||||
time: { created: 1 },
|
||||
} as unknown as PermissionV2Request
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
// Mounted like the permission dialog: before the tool call has settled.
|
||||
input = usePermissionInput(request)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
expect(input()).toEqual({})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_perm_tool_started",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: `session_${sessionID}`, seq: 0, version: 1 },
|
||||
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission", name: "shell" },
|
||||
} as unknown as OpenCodeEvent)
|
||||
emitEvent(events, {
|
||||
id: "evt_perm_tool_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: { aggregateID: `session_${sessionID}`, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
callID: "call-permission",
|
||||
input: { command: "rm -rf ./dist" },
|
||||
timestamp: 2,
|
||||
},
|
||||
} as unknown as OpenCodeEvent)
|
||||
|
||||
// The prompt is already mounted; the resolved input must flow through the
|
||||
// part slot once the tool call settles out of input streaming.
|
||||
await wait(() => {
|
||||
const value = input()
|
||||
return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist"
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue