mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 13:36:18 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b530418425 | ||
|
|
e70d667a9f | ||
|
|
8ba434b597 |
@@ -1343,20 +1343,28 @@ const onMessageDelta = (
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
const pendingFinish = (() => {
|
||||
const stopReason = event.delta?.stop_reason
|
||||
if (stopReason === null || stopReason === undefined) return state.pendingFinish
|
||||
|
||||
const stopSequence = event.delta?.stop_sequence
|
||||
const finishMetadata =
|
||||
stopSequence === null || stopSequence === undefined
|
||||
? state.pendingFinish?.providerMetadata
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence })
|
||||
return {
|
||||
reason: {
|
||||
normalized: mapFinishReason(stopReason),
|
||||
raw: stopReason,
|
||||
},
|
||||
providerMetadata: finishMetadata,
|
||||
}
|
||||
})()
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
pendingFinish,
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
|
||||
@@ -949,6 +949,41 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal state across usage-only message deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: "X" },
|
||||
usage: { output_tokens: 8 },
|
||||
},
|
||||
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
||||
providerMetadata: { anthropic: { stopSequence: "X" } },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
||||
providerMetadata: { anthropic: { stopSequence: "X" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires message_stop before completing a streamed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
+11
-113
@@ -3,9 +3,8 @@ export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
@@ -13,14 +12,13 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
@@ -58,7 +56,6 @@ import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
// get project -> project.locations
|
||||
@@ -72,30 +69,8 @@ import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
export { ListAnchor }
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
|
||||
anchor: ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const ListDirectoryInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ListProjectInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListAllInput = Schema.Struct(ListInputBase)
|
||||
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
export const ListInput = SessionStore.ListInput
|
||||
export type ListInput = SessionStore.ListInput
|
||||
|
||||
type CreateBaseInput = {
|
||||
id?: SessionSchema.ID
|
||||
@@ -161,15 +136,9 @@ export interface Interface {
|
||||
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
|
||||
readonly view: (input: { sessionID: SessionSchema.ID; idle: number }) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly messages: (
|
||||
input: SessionStore.MessagesInput,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
@@ -409,83 +378,12 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
yield* bus.remove(sessionID)
|
||||
}),
|
||||
list: Effect.fn("Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return { data: (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }
|
||||
list: Effect.fn("Session.list")(function* (input) {
|
||||
return { data: yield* store.list(input) }
|
||||
}),
|
||||
messages: Effect.fn("Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
return yield* store.messages(input)
|
||||
}),
|
||||
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
|
||||
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export * as SessionStore from "./store.js"
|
||||
|
||||
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionHistory } from "./history.js"
|
||||
@@ -11,8 +14,45 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { fromRow } from "./info.js"
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
|
||||
anchor: Session.ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const ListDirectoryInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ListProjectInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListAllInput = Schema.Struct(ListInputBase)
|
||||
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
@@ -55,6 +95,83 @@ const layer = Layer.effect(
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
list: Effect.fn("SessionStore.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("SessionStore.messages")(function* (input) {
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { fromRow } from "@opencode-ai/core/session/info"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
@@ -32,9 +33,10 @@ import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[[Bus.node, Bus.configured({ persist: true })]],
|
||||
),
|
||||
)
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
|
||||
const sessionID = Session.ID.make("ses_projector_test")
|
||||
@@ -278,7 +280,9 @@ describe("SessionProjector", () => {
|
||||
yield* db.run(sql`update session_message set data = '{"time":{"created":0}}' where id = ${messageID}`)
|
||||
|
||||
const sessions = yield* Session.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const expected = { _tag: "Session.MessageDecodeError", sessionID, messageID }
|
||||
expect(yield* store.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.context(sessionID).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.message({ sessionID, messageID }).pipe(Effect.catchDefect(Effect.succeed))).toMatchObject(
|
||||
@@ -287,6 +291,21 @@ describe("SessionProjector", () => {
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("checks session existence before resolving a missing message cursor", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const missing = Session.ID.make("ses_missing")
|
||||
expect(
|
||||
yield* sessions
|
||||
.messages({
|
||||
sessionID: missing,
|
||||
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
|
||||
})
|
||||
.pipe(Effect.flip),
|
||||
).toEqual(new Session.NotFoundError({ sessionID: missing }))
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
)
|
||||
|
||||
const seedSessions = (rows: { id: string; updated: number }[]) =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const directory = AbsolutePath.make("/project")
|
||||
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: directory, sandboxes: [] }).run()
|
||||
yield* Effect.forEach(rows, (row) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make(row.id)
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: { directory },
|
||||
slug: "store-test",
|
||||
version: "test",
|
||||
})
|
||||
yield* bus.replay({
|
||||
id: Event.ID.create(),
|
||||
created: row.updated,
|
||||
aggregateID: sessionID,
|
||||
seq: 1,
|
||||
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
|
||||
data: { sessionID, title: row.id },
|
||||
})
|
||||
}),
|
||||
)
|
||||
return bus
|
||||
})
|
||||
|
||||
describe("SessionStore", () => {
|
||||
it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedSessions([
|
||||
{ id: "ses_d", updated: 20 },
|
||||
{ id: "ses_z", updated: 10 },
|
||||
{ id: "ses_a", updated: 30 },
|
||||
{ id: "ses_c", updated: 20 },
|
||||
{ id: "ses_y", updated: 10 },
|
||||
{ id: "ses_e", updated: 30 },
|
||||
{ id: "ses_b", updated: 20 },
|
||||
])
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.list()).map((session) => String(session.id))).toEqual([
|
||||
"ses_e",
|
||||
"ses_a",
|
||||
"ses_d",
|
||||
"ses_c",
|
||||
"ses_b",
|
||||
"ses_z",
|
||||
"ses_y",
|
||||
])
|
||||
expect((yield* store.list({ order: "asc" })).map((session) => String(session.id))).toEqual([
|
||||
"ses_y",
|
||||
"ses_z",
|
||||
"ses_b",
|
||||
"ses_c",
|
||||
"ses_d",
|
||||
"ses_a",
|
||||
"ses_e",
|
||||
])
|
||||
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
|
||||
{ order: "asc", direction: "next", ids: ["ses_d", "ses_a"] },
|
||||
{ order: "asc", direction: "previous", ids: ["ses_z", "ses_b"] },
|
||||
{ order: "desc", direction: "next", ids: ["ses_b", "ses_z"] },
|
||||
{ order: "desc", direction: "previous", ids: ["ses_a", "ses_d"] },
|
||||
]
|
||||
yield* Effect.forEach(pages, (page) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* store.list({
|
||||
order: page.order,
|
||||
limit: 2,
|
||||
anchor: { id: Session.ID.make("ses_c"), time: 20, direction: page.direction },
|
||||
})
|
||||
expect(sessions.map((session) => String(session.id))).toEqual(page.ids)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("pages messages by durable sequence, not timestamp or ID, and scopes cursor lookup", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make("ses_messages")
|
||||
const foreignID = Session.ID.make("ses_foreign")
|
||||
const bus = yield* seedSessions([
|
||||
{ id: sessionID, updated: 0 },
|
||||
{ id: foreignID, updated: 0 },
|
||||
])
|
||||
const store = yield* SessionStore.Service
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ id: "evt_z", created: 300 },
|
||||
{ id: "evt_b", created: 700 },
|
||||
{ id: "evt_x", created: 100 },
|
||||
{ id: "evt_c", created: 400 },
|
||||
{ id: "evt_w", created: 200 },
|
||||
{ id: "evt_a", created: 600 },
|
||||
{ id: "evt_y", created: 500 },
|
||||
],
|
||||
(event, index) =>
|
||||
bus.replay({
|
||||
id: Event.ID.make(event.id),
|
||||
created: event.created,
|
||||
aggregateID: sessionID,
|
||||
seq: index + 2,
|
||||
type: Bus.versionedType(SessionEvent.Synthetic.type, 1),
|
||||
data: { sessionID, text: event.id },
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID: foreignID, text: "foreign" },
|
||||
{
|
||||
id: Event.ID.make("evt_foreign"),
|
||||
},
|
||||
)
|
||||
expect((yield* store.messages({ sessionID })).map((message) => String(message.id))).toEqual([
|
||||
"msg_y",
|
||||
"msg_a",
|
||||
"msg_w",
|
||||
"msg_c",
|
||||
"msg_x",
|
||||
"msg_b",
|
||||
"msg_z",
|
||||
])
|
||||
expect((yield* store.messages({ sessionID, order: "asc" })).map((message) => String(message.id))).toEqual([
|
||||
"msg_z",
|
||||
"msg_b",
|
||||
"msg_x",
|
||||
"msg_c",
|
||||
"msg_w",
|
||||
"msg_a",
|
||||
"msg_y",
|
||||
])
|
||||
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
|
||||
{ order: "asc", direction: "next", ids: ["msg_w", "msg_a"] },
|
||||
{ order: "asc", direction: "previous", ids: ["msg_b", "msg_x"] },
|
||||
{ order: "desc", direction: "next", ids: ["msg_x", "msg_b"] },
|
||||
{ order: "desc", direction: "previous", ids: ["msg_a", "msg_w"] },
|
||||
]
|
||||
yield* Effect.forEach(pages, (page) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* store.messages({
|
||||
sessionID,
|
||||
order: page.order,
|
||||
limit: 2,
|
||||
cursor: { id: SessionMessage.ID.make("msg_c"), direction: page.direction },
|
||||
})
|
||||
expect(messages.map((message) => String(message.id))).toEqual(page.ids)
|
||||
}),
|
||||
)
|
||||
expect(yield* store.messages({ sessionID: Session.ID.make("ses_missing") })).toEqual([])
|
||||
expect(
|
||||
yield* store.messages({
|
||||
sessionID,
|
||||
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
|
||||
}),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* store.messages({
|
||||
sessionID,
|
||||
order: "asc",
|
||||
cursor: { id: SessionMessage.ID.make("msg_foreign"), direction: "next" },
|
||||
}),
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -126,6 +127,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[Session.ID, schemaSession.Session.ID],
|
||||
[Session.Info, schemaSession.Session.Info],
|
||||
[Session.ListAnchor, schemaSession.Session.ListAnchor],
|
||||
[Session.ListInput, SessionStore.ListInput],
|
||||
[coreSessionInbox.Delivery, SessionInbox.Delivery],
|
||||
[coreSessionInbox.Item, SessionInbox.Item],
|
||||
[coreSessionInbox.User, SessionInbox.User],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const id = "current-session-tool-headers--shared-headers"
|
||||
|
||||
story("shares compact title and detail metrics across tool families", async ({ mount }, info) => {
|
||||
const root = await mount(id)
|
||||
await root.getByRole("button", { name: /^Used 7 / }).click()
|
||||
const headers = root.locator('[data-component="context-tool-group-list"] [data-component="tool-header"]')
|
||||
await expect(headers).toHaveCount(7)
|
||||
const titles = headers.locator('[data-slot="basic-tool-tool-title"]')
|
||||
await expect(headers.locator('[data-component="text-shimmer"][aria-label="Write"]')).toBeVisible()
|
||||
await expect(headers.locator('[data-component="text-shimmer"][aria-label="Edit"]')).toBeVisible()
|
||||
for (const title of await titles.all()) {
|
||||
await expect(title).toBeVisible()
|
||||
await expect(title).toHaveCSS("font-family", /^Inter,/)
|
||||
await expect(title).toHaveCSS("font-size", "13px")
|
||||
await expect(title).toHaveCSS("line-height", "16px")
|
||||
await expect(title).toHaveCSS("font-weight", "530")
|
||||
await expect(title.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
|
||||
}
|
||||
const details = headers.locator(
|
||||
'[data-slot="basic-tool-tool-subtitle"], [data-slot="basic-tool-tool-arg"], [data-slot="tool-header-directory"]',
|
||||
)
|
||||
expect(await details.count()).toBeGreaterThan(7)
|
||||
for (const detail of await details.all()) {
|
||||
await expect(detail).toHaveCSS("font-size", "13px")
|
||||
await expect(detail).toHaveCSS("line-height", "16px")
|
||||
await expect(detail).toHaveCSS("font-weight", "440")
|
||||
}
|
||||
await expect(headers.locator('[data-slot="basic-tool-tool-arg"]')).toHaveText([
|
||||
"offset=12",
|
||||
"limit=40",
|
||||
"pattern=header",
|
||||
"include=*.tsx",
|
||||
])
|
||||
await root.locator('[data-component="session-timeline"]').screenshot({ path: info.outputPath("tool-headers.png") })
|
||||
})
|
||||
|
||||
story("keeps pending file titles active without showing unfinished paths", async ({ mount }) => {
|
||||
const root = await mount(id, { args: { phase: "streaming", pathKnown: false } })
|
||||
await root
|
||||
.locator(
|
||||
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
|
||||
)
|
||||
.click()
|
||||
for (const action of [undefined, "Provide paths", "Run tools"]) {
|
||||
if (action) await root.getByRole("button", { name: action, exact: true }).click()
|
||||
for (const name of ["edit", "write"]) {
|
||||
const header = root.locator(`[data-timeline-part-id="tool_header_${name}"] [data-component="tool-header"]`)
|
||||
await expect(header).toBeVisible()
|
||||
await expect(header.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("line-height", "16px")
|
||||
await expect(
|
||||
header.locator('[data-slot="basic-tool-tool-subtitle"], [data-slot="tool-header-directory"]'),
|
||||
).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await root.getByRole("button", { name: "Complete tools", exact: true }).click()
|
||||
const group = root.getByRole("button", { name: /^Used 7 / })
|
||||
if ((await group.getAttribute("aria-expanded")) === "false") await group.click()
|
||||
for (const name of ["edit", "write"]) {
|
||||
const header = root.locator(`[data-timeline-part-id="tool_header_${name}"] [data-component="tool-header"]`)
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText(`${name}.ts`)
|
||||
await expect(header.locator('[data-slot="tool-header-directory"]')).toContainText("src/components")
|
||||
await expect(header.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
|
||||
}
|
||||
})
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const width of [390, 1000]) {
|
||||
story(`truncates long file headers at ${width}px in ${theme}`, async ({ mount, page }, info) => {
|
||||
await page.setViewportSize({ width, height: 850 })
|
||||
const root = await mount(id, { args: { longPath: true }, globals: { theme } })
|
||||
await root.getByRole("button", { name: /^Used 7 / }).click()
|
||||
for (const name of ["edit", "write"]) {
|
||||
const header = root.locator(`[data-timeline-part-id="tool_header_${name}"] [data-component="tool-header"]`)
|
||||
const filename = header.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
const directory = header.locator('[data-slot="tool-header-directory"] > span')
|
||||
await expect(filename).toContainText(`${name}.ts`)
|
||||
for (const text of [filename, directory]) {
|
||||
await expect(text).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(text).toHaveCSS("white-space", "nowrap")
|
||||
await expect(text).toHaveCSS("line-height", "16px")
|
||||
}
|
||||
expect(await filename.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
const bounds = await header.boundingBox()
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width)
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
.screenshot({ path: info.outputPath("long-headers.png") })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("preserves keyboard disclosures and the webfetch link", async ({ mount }) => {
|
||||
const root = await mount(id)
|
||||
await root.getByRole("button", { name: /^Used 7 / }).click()
|
||||
for (const name of ["shell", "execute", "edit", "write"]) {
|
||||
const row = root.locator(`[data-timeline-part-id="tool_header_${name}"]`)
|
||||
const trigger = row.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const content = row.locator('[data-slot="collapsible-content"]').first()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.focus()
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(content).toBeVisible()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("Space")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(content).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
}
|
||||
const link = root.getByRole("link", { name: "https://example.com/docs" })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toHaveAttribute("href", "https://example.com/docs")
|
||||
await expect(link).toHaveAttribute("target", "_blank")
|
||||
await expect(link).toHaveAttribute("rel", /noopener/)
|
||||
await expect(link).toHaveCSS("font-size", "13px")
|
||||
await expect(link).toHaveCSS("font-weight", "440")
|
||||
await expect(link).toHaveCSS("line-height", "16px")
|
||||
await expect(link).toHaveCSS("letter-spacing", "-0.04px")
|
||||
await link.focus()
|
||||
await expect(link).toBeFocused()
|
||||
})
|
||||
@@ -83,22 +83,11 @@
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&.agent-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
@@ -107,13 +96,6 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
&.clickable:not(.webfetch-link) {
|
||||
@@ -153,13 +135,6 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@@ -177,23 +152,6 @@
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
/* Keep compact text on the shared metric; solid 13px line boxes clip Inter descenders. */
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-card"] {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
@@ -16,17 +15,9 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import type { IconProps } from "@opencode-ai/ui/icon"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { ToolHeader, type ToolHeaderProps } from "./tool-header"
|
||||
|
||||
export type TriggerTitle = {
|
||||
title: string
|
||||
titleClass?: string
|
||||
subtitle?: string
|
||||
subtitleClass?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
export type TriggerTitle = Omit<ToolHeaderProps, "active" | "onSubtitleClick">
|
||||
|
||||
const isTriggerTitle = (val: unknown): val is TriggerTitle => {
|
||||
if (typeof val !== "object" || val === null) return false
|
||||
@@ -216,54 +207,12 @@ export function BasicTool(props: BasicToolProps) {
|
||||
<Switch>
|
||||
<Match when={triggerTitle()}>
|
||||
{(title) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
classList={{
|
||||
[title().titleClass ?? ""]: !!title().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={title().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() || title().subtitle || title().args?.length}>
|
||||
<Show when={title().subtitle}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
classList={{
|
||||
[title().subtitleClass ?? ""]: !!title().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={title().args?.length}>
|
||||
<For each={title().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[title().argsClass ?? ""]: !!title().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && title().action}>
|
||||
<span data-slot="basic-tool-tool-action">{title().action}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolHeader
|
||||
{...title()}
|
||||
active={pending()}
|
||||
onSubtitleClick={props.onSubtitleClick}
|
||||
action={!pending() ? title().action : undefined}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{triggerContent() as JSX.Element}</Match>
|
||||
|
||||
@@ -446,102 +446,6 @@
|
||||
--tool-content-gap: 6px;
|
||||
}
|
||||
|
||||
[data-component="edit-trigger"],
|
||||
[data-component="write-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
[data-slot="message-part-title-area"] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-title"] {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-spinner"] {
|
||||
margin-left: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-text"] {
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-filename"] {
|
||||
/* No text-transform - preserve original filename casing */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--font-weight-regular);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
[data-slot="message-part-path"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
font-weight: var(--font-weight-regular);
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
[data-slot="message-part-directory"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="message-part-filename"] {
|
||||
color: var(--v2-text-text-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-content"] {
|
||||
border-radius: inherit;
|
||||
border-top: 0.5px solid var(--v2-border-border-muted);
|
||||
@@ -678,24 +582,6 @@
|
||||
gap: 0px;
|
||||
cursor: default;
|
||||
|
||||
[data-slot="context-tool-group-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-prefix"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -740,17 +626,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"],
|
||||
[data-slot="basic-tool-tool-subtitle"],
|
||||
[data-slot="basic-tool-tool-arg"],
|
||||
[data-slot="context-tool-group-matches"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
[data-component="tool-header"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
/* Truncated text still needs room for Inter descenders. */
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
&[data-slot="basic-tool-tool-info-structured"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
flex-shrink: 0;
|
||||
font: inherit;
|
||||
font-weight: 530;
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tool-header-affix"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"],
|
||||
[data-slot="basic-tool-tool-arg"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[dir] {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tool-header-directory"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
unicode-bidi: isolate;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webfetch-link {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { children, For, Show, type JSX } from "solid-js"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title: string
|
||||
active?: boolean
|
||||
titleClass?: string
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
subtitle?: JSX.Element
|
||||
subtitleClass?: string
|
||||
subtitleDir?: "ltr" | "rtl"
|
||||
directory?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
onSubtitleClick?: () => void
|
||||
}
|
||||
|
||||
/** Shared presentation for tool rows; callers own values, status, and disclosure. */
|
||||
export function ToolHeader(props: ToolHeaderProps) {
|
||||
const subtitle = children(() => props.subtitle)
|
||||
const action = children(() => props.action)
|
||||
return (
|
||||
<div data-component="tool-header" data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<Show when={props.prefix}>
|
||||
<span data-slot="tool-header-affix">{props.prefix}</span>
|
||||
</Show>
|
||||
<span data-slot="basic-tool-tool-title" class={props.titleClass}>
|
||||
<Show when={props.active !== undefined} fallback={props.title}>
|
||||
<TextShimmer text={props.title} active={props.active} />
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={props.suffix}>
|
||||
<span data-slot="tool-header-affix">{props.suffix}</span>
|
||||
</Show>
|
||||
<Show when={subtitle()}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
dir={props.subtitleDir}
|
||||
classList={{
|
||||
[props.subtitleClass ?? ""]: !!props.subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!props.onSubtitleClick) return
|
||||
event.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}}
|
||||
>
|
||||
{subtitle()}
|
||||
</span>
|
||||
</Show>
|
||||
<For each={props.args}>
|
||||
{(arg) => (
|
||||
<span data-slot="basic-tool-tool-arg" class={props.argsClass}>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={props.directory}>
|
||||
<span data-slot="tool-header-directory" dir="ltr">
|
||||
<span>{props.directory}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={action()}>
|
||||
<span data-slot="basic-tool-tool-action">{action()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { BasicTool } from "../components/basic-tool"
|
||||
import { reasoningHeading } from "../timeline/projection"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
@@ -534,30 +533,14 @@ export function AssistantReasoningContent(props: {
|
||||
props.onOpenChange?.(value)
|
||||
props.onContentRendered?.()
|
||||
}}
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought")}
|
||||
active={props.streaming}
|
||||
/>
|
||||
</span>
|
||||
<Show
|
||||
when={props.streaming && !open()}
|
||||
fallback={
|
||||
<Show when={!props.streaming && duration()}>
|
||||
{(value) => <span data-slot="basic-tool-tool-subtitle">{value()}</span>}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<TextReveal text={heading()} />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger={{
|
||||
title: i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought"),
|
||||
subtitle: (
|
||||
<Show when={props.streaming && !open()} fallback={!props.streaming ? duration() : undefined}>
|
||||
<TextReveal text={heading()} />
|
||||
</Show>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<PacedMarkdown text={props.content.text} cacheKey={props.id} streaming={props.streaming} />
|
||||
</BasicTool>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@import "../components/basic-tool.css" layer(components);
|
||||
@import "../components/tool-header.css" layer(components);
|
||||
@import "../components/file.css" layer(components);
|
||||
@import "../components/markdown.css" layer(components);
|
||||
@import "../components/message-part.css" layer(components);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { SessionTimeline } from "./session-timeline"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Work/Tool headers",
|
||||
id: "current-session-tool-headers",
|
||||
component: SessionTimeline,
|
||||
parameters: { layout: "fullscreen" },
|
||||
}
|
||||
|
||||
export const SharedHeaders = {
|
||||
args: { phase: "completed", pathKnown: true, longPath: false },
|
||||
argTypes: { phase: { control: "select", options: ["streaming", "running", "completed"] } },
|
||||
render: (args: { phase: "streaming" | "running" | "completed"; pathKnown: boolean; longPath: boolean }) => {
|
||||
const [phase, setPhase] = createSignal(args.phase)
|
||||
const [known, setKnown] = createSignal(args.pathKnown)
|
||||
const path = (name: string) =>
|
||||
args.longPath
|
||||
? `src/components/session/timeline/tools/deeply/nested/directory/with/a/long/path/${"long-filename-".repeat(8)}${name}.ts`
|
||||
: `src/components/${name}.ts`
|
||||
const document = createMemo(() =>
|
||||
storyDocument(
|
||||
[
|
||||
storyTool("tool_header_read", "read", phase(), { path: path("read"), offset: 12, limit: 40 }),
|
||||
storyTool(
|
||||
"tool_header_grep",
|
||||
"grep",
|
||||
phase(),
|
||||
{ path: "src/components", pattern: "header", include: "*.tsx" },
|
||||
{ metadata: { matches: 3 } },
|
||||
),
|
||||
storyTool("tool_header_shell", "shell", phase(), { command: "printf checked" }, { output: "checked" }),
|
||||
storyTool(
|
||||
"tool_header_execute",
|
||||
"execute",
|
||||
phase(),
|
||||
{ code: 'console.log("checked")' },
|
||||
{ output: "checked" },
|
||||
),
|
||||
storyTool("tool_header_webfetch", "webfetch", phase(), { url: "https://example.com/docs" }),
|
||||
storyTool("tool_header_edit", "edit", phase(), {
|
||||
...(known() ? { path: path("edit") } : {}),
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
}),
|
||||
storyTool("tool_header_write", "write", phase(), {
|
||||
...(known() ? { path: path("write") } : {}),
|
||||
content: "export const written = true\n",
|
||||
}),
|
||||
],
|
||||
phase() !== "completed",
|
||||
),
|
||||
)
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[840px] flex-col gap-4 p-6">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" onClick={() => setKnown(true)}>
|
||||
Provide paths
|
||||
</button>
|
||||
<button type="button" onClick={() => setPhase("running")}>
|
||||
Run tools
|
||||
</button>
|
||||
<button type="button" onClick={() => setPhase("completed")}>
|
||||
Complete tools
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPhase(args.phase)
|
||||
setKnown(args.pathKnown)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<SessionTimeline document={document()} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { type SessionSummary, useData } from "../context"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { ToolHeader } from "../components/tool-header"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
@@ -560,15 +561,7 @@ export function CurrentContextToolGroup(props: {
|
||||
onOpenChange={change}
|
||||
trigger={
|
||||
<div data-component="context-tool-group-trigger" aria-label={label().text}>
|
||||
<span data-slot="context-tool-group-title">
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
|
||||
</Show>
|
||||
<span data-slot="basic-tool-tool-title">{label().title}</span>
|
||||
<Show when={label().after}>
|
||||
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
|
||||
</Show>
|
||||
</span>
|
||||
<ToolHeader title={label().title} prefix={label().before} suffix={label().after} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -689,32 +682,22 @@ export function CurrentContextToolGroup(props: {
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={trigger().title}
|
||||
active={
|
||||
tool().state.status === "streaming" || tool().state.status === "running"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
<ToolHeader
|
||||
title={trigger().title}
|
||||
subtitle={trigger().subtitle}
|
||||
args={trigger().args}
|
||||
active={tool().state.status === "streaming" || tool().state.status === "running"}
|
||||
action={
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1209,28 +1192,23 @@ ToolRegistry.register({
|
||||
{...props}
|
||||
hideDetails
|
||||
icon="window-cursor"
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() && url()}>
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="webfetch-link"
|
||||
href={url()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span data-slot="webfetch-link-text">{url()}</span>
|
||||
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.webfetch"),
|
||||
subtitle: (
|
||||
<Show when={!pending() && url()}>
|
||||
<a
|
||||
class="webfetch-link"
|
||||
href={url()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span data-slot="webfetch-link-text">{url()}</span>
|
||||
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
|
||||
</a>
|
||||
</Show>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -1443,16 +1421,15 @@ ToolRegistry.register({
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
|
||||
</span>
|
||||
<ToolHeader
|
||||
title={i18n.t("ui.tool.execute")}
|
||||
active={pending()}
|
||||
subtitle={
|
||||
<Show when={!open() && code()}>
|
||||
<ShellSubmessage text={code().split("\n")[0]} animate={sawPending} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ConsoleOutput copy={code()} variant="shell">
|
||||
@@ -1532,25 +1509,20 @@ ToolRegistry.register({
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
||||
</span>
|
||||
<ToolHeader
|
||||
title={i18n.t("ui.tool.shell")}
|
||||
active={pending()}
|
||||
subtitle={
|
||||
<Show when={!open()}>
|
||||
<Show
|
||||
when={command()}
|
||||
fallback={
|
||||
<Show when={streaming()}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{i18n.t("ui.tool.shell.writingCommand")}</span>
|
||||
</Show>
|
||||
}
|
||||
fallback={<Show when={streaming()}>{i18n.t("ui.tool.shell.writingCommand")}</Show>}
|
||||
>
|
||||
{(command) => <ShellSubmessage text={command()} animate={sawStreaming} />}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ConsoleOutput copy={command()} variant="shell">
|
||||
@@ -1663,30 +1635,13 @@ ToolRegistry.register({
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{filename()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && inputPath().includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{displayDirectory(inputPath())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<Show when={!pending() ? diff() : undefined}>
|
||||
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger={{
|
||||
title: i18n.t("ui.messagePart.title.edit"),
|
||||
subtitle: !pending() ? filename() : undefined,
|
||||
subtitleDir: "ltr",
|
||||
directory: !pending() && inputPath().includes("/") ? displayDirectory(inputPath()) : undefined,
|
||||
action: <Show when={diff()}>{(diff) => <DiffChanges appearance="standard" changes={diff()} />}</Show>,
|
||||
}}
|
||||
>
|
||||
<Show when={path()}>
|
||||
<ToolFileAccordion
|
||||
@@ -1732,26 +1687,12 @@ ToolRegistry.register({
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{filename()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && path().includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{displayDirectory(path())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
}
|
||||
trigger={{
|
||||
title: i18n.t("ui.messagePart.title.write"),
|
||||
subtitle: !pending() ? filename() : undefined,
|
||||
subtitleDir: "ltr",
|
||||
directory: !pending() && path().includes("/") ? displayDirectory(path()) : undefined,
|
||||
}}
|
||||
>
|
||||
<Show when={content() && path()}>
|
||||
<ToolFileAccordion path={path()}>
|
||||
|
||||
Reference in New Issue
Block a user