mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 08:25:00 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a22a0251e |
@@ -67,7 +67,6 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
@@ -521,7 +520,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
@@ -12,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
return session ? displayLabel(session) : props.fallbackTitle
|
||||
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
|
||||
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
|
||||
|
||||
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
if (!value) return []
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
}): JSX.Element => {
|
||||
const title = () => displayLabel(props.session)
|
||||
const title = () => sessionTitle(props.session.title, props.session.parentID)
|
||||
|
||||
return (
|
||||
<A
|
||||
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={displayLabel(props.session)}
|
||||
value={sessionTitle(props.session.title, props.session.parentID)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
@@ -296,10 +296,11 @@ export function MessageTimeline(props: {
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => {
|
||||
const session = info()
|
||||
if (!session) return
|
||||
return displayLabel(session)
|
||||
return sessionTitle(titleValue(), session.parentID)
|
||||
})
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
@@ -316,7 +317,7 @@ export function MessageTimeline(props: {
|
||||
})
|
||||
const parentTitle = createMemo(() => {
|
||||
const session = parent()
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
|
||||
})
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
@@ -920,7 +921,7 @@ export function MessageTimeline(props: {
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => {
|
||||
const session = sync().session.get(props.sessionID)
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
|
||||
})
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sessionTitle } from "./session-title"
|
||||
|
||||
describe("sessionTitle", () => {
|
||||
test("uses a display fallback without persisting it", () => {
|
||||
expect(sessionTitle(undefined)).toBe("New session")
|
||||
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
|
||||
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
|
||||
expect(sessionTitle("Generated title")).toBe("Generated title")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionTitle(title?: string, parentID?: string) {
|
||||
if (!title) return parentID ? "Child session" : "New session"
|
||||
const match = title.match(pattern)
|
||||
return match?.[1] ?? title
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: withTimestampedFallback(input),
|
||||
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type SessionMessageInfo,
|
||||
type SkillInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
@@ -214,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessions: page.data.map((session) => ({
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
title: withTimestampedFallback(session),
|
||||
title:
|
||||
session.title ??
|
||||
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
})),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Agent } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
@@ -40,10 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
|
||||
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
||||
const isUntitled = (session: SessionSchema.Info) =>
|
||||
isExactRootFallback({
|
||||
title: session.title,
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
})
|
||||
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Context, Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
@@ -16,14 +16,7 @@ import { Tool } from "../tool"
|
||||
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
|
||||
export interface Interface {
|
||||
readonly reconcile: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const tools = yield* Tool.Service
|
||||
@@ -125,12 +118,11 @@ export const layer = Layer.effect(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ reconcile })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
name: "mcp-tools",
|
||||
layer,
|
||||
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
|
||||
})
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
|
||||
test("explicitly fences asynchronous MCP tool reconciliation", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const initialRead = yield* Deferred.make<void>()
|
||||
const reconcileStarted = yield* Deferred.make<void>()
|
||||
const releaseReconcile = yield* Deferred.make<void>()
|
||||
const updates = yield* PubSub.unbounded<void>()
|
||||
let reads = 0
|
||||
let catalog: Array<MCP.Tool> = []
|
||||
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () =>
|
||||
Effect.gen(function* () {
|
||||
reads += 1
|
||||
if (reads === 1) {
|
||||
const current = catalog
|
||||
yield* Deferred.succeed(initialRead, undefined)
|
||||
return current
|
||||
}
|
||||
yield* Deferred.succeed(reconcileStarted, undefined)
|
||||
yield* Deferred.await(releaseReconcile)
|
||||
return catalog
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[Bus.node, Layer.mock(Bus.Service, { subscribe: () => Stream.fromPubSub(updates) as never })],
|
||||
[Permission.node, Layer.mock(Permission.Service, {})],
|
||||
[Image.node, imagePassthrough],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const adapter = yield* McpTool.Service
|
||||
yield* Deferred.await(initialRead)
|
||||
catalog = [
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("voice"),
|
||||
name: "list_open_tabs",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
]
|
||||
|
||||
yield* PubSub.publish(updates, undefined)
|
||||
yield* Deferred.await(reconcileStarted)
|
||||
|
||||
const stale = yield* registry.snapshot()
|
||||
expect(stale.codeModeCatalog?.some((entry) => entry.path === "voice.list_open_tabs")).toBe(false)
|
||||
|
||||
const fence = yield* Effect.forkChild(adapter.reconcile, { startImmediately: true })
|
||||
expect(fence.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(releaseReconcile, undefined)
|
||||
yield* Fiber.join(fence)
|
||||
const current = yield* registry.snapshot()
|
||||
expect(current.codeModeCatalog?.some((entry) => entry.path === "voice.list_open_tabs")).toBe(true)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -16,7 +16,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { Share } from "~/core/share"
|
||||
@@ -159,7 +158,11 @@ export default function () {
|
||||
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
|
||||
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
|
||||
const info = createMemo(() => data().session[match().index])
|
||||
const title = createMemo(() => withTimestampedFallback(info()))
|
||||
const title = createMemo(
|
||||
() =>
|
||||
info().title ??
|
||||
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
|
||||
)
|
||||
const ogImage = createMemo(() => {
|
||||
const models = new Set<string>()
|
||||
const messages = data().message[data().sessionID] ?? []
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
@@ -31,9 +30,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
"mcp.add",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const tools = yield* McpTool.Service
|
||||
yield* service.add(ctx.params.server, ctx.payload.config)
|
||||
yield* tools.reconcile
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -41,9 +38,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
"mcp.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const tools = yield* McpTool.Service
|
||||
yield* notFound(service.remove(ctx.params.server))
|
||||
yield* tools.reconcile
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -51,9 +46,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
"mcp.connect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const tools = yield* McpTool.Service
|
||||
yield* notFound(service.connect(ctx.params.server))
|
||||
yield* tools.reconcile
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -61,9 +54,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
"mcp.disconnect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const tools = yield* McpTool.Service
|
||||
yield* notFound(service.disconnect(ctx.params.server))
|
||||
yield* tools.reconcile
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
@@ -140,44 +141,46 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
</Show>
|
||||
|
||||
<div class="relative min-h-[60px]">
|
||||
<div
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
props.controller.setEditor(element)
|
||||
renderPromptInputV2Editor(element, props.controller.parts())
|
||||
}}
|
||||
data-component="prompt-input"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Prompt"
|
||||
contenteditable={!props.disabled && !props.readOnly}
|
||||
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={state.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={state.mode === "normal"}
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
onInput={(event) => {
|
||||
const cursor = promptInputV2Cursor(event.currentTarget)
|
||||
const prompt = parsePromptInputV2Editor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (props.controller.onKeyDown(event)) return
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
if (event.repeat) return
|
||||
props.controller.submit()
|
||||
}
|
||||
}}
|
||||
onKeyUp={updateCursor}
|
||||
onPointerUp={updateCursor}
|
||||
onPaste={props.controller.onPaste}
|
||||
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
|
||||
/>
|
||||
<ScrollView class="min-h-[60px] max-h-[180px]">
|
||||
<div
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
props.controller.setEditor(element)
|
||||
renderPromptInputV2Editor(element, props.controller.parts())
|
||||
}}
|
||||
data-component="prompt-input"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Prompt"
|
||||
contenteditable={!props.disabled && !props.readOnly}
|
||||
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={state.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={state.mode === "normal"}
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
onInput={(event) => {
|
||||
const cursor = promptInputV2Cursor(event.currentTarget)
|
||||
const prompt = parsePromptInputV2Editor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (props.controller.onKeyDown(event)) return
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
if (event.repeat) return
|
||||
props.controller.submit()
|
||||
}
|
||||
}}
|
||||
onKeyUp={updateCursor}
|
||||
onPointerUp={updateCursor}
|
||||
onPaste={props.controller.onPaste}
|
||||
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
|
||||
/>
|
||||
</ScrollView>
|
||||
<Show when={!props.controller.value()}>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
|
||||
@@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import { isDefaultTitle } from "./util/session"
|
||||
import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
@@ -537,7 +537,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
|
||||
if (route.data.type === "session") {
|
||||
const title = session?.title
|
||||
if (!title || isFallbackTitle(title)) {
|
||||
if (!title || isDefaultTitle(title)) {
|
||||
renderer.setTerminalTitle("OpenCode")
|
||||
return
|
||||
}
|
||||
@@ -646,10 +646,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
run: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined,
|
||||
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
@@ -105,7 +105,7 @@ export const settings: Setting[] = [
|
||||
title: "Scope",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "scope"],
|
||||
default: "global",
|
||||
default: "cwd",
|
||||
values: ["cwd", "global"],
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../util/session"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
@@ -80,11 +80,10 @@ export function DialogOpen() {
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
title: sessionTitle(session),
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
? () => <Spinner />
|
||||
: tabs.has(session.id)
|
||||
|
||||
@@ -18,8 +18,7 @@ import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../util/session"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -31,15 +30,12 @@ export function DialogSessionList() {
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const config = useConfig().data
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", {
|
||||
initial: { allProjects: config.tabs?.scope !== "cwd" },
|
||||
})
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
|
||||
const allProjects = () => prefs.allProjects
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
@@ -82,9 +78,7 @@ export function DialogSessionList() {
|
||||
(session.projectID === current?.project.id && session.location.directory === current.directory),
|
||||
)
|
||||
if (!query) return sessions
|
||||
return sessions.filter(
|
||||
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
|
||||
)
|
||||
return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query))
|
||||
})
|
||||
const sessions = createMemo(() => {
|
||||
const query = filter().trim()
|
||||
@@ -146,9 +140,7 @@ export function DialogSessionList() {
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
title: deleting
|
||||
? `Press ${shortcuts.get("session.delete")} again to confirm`
|
||||
: withTimestampedFallback(session),
|
||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(session),
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
@@ -244,9 +236,7 @@ export function DialogSessionList() {
|
||||
.remove({ sessionID: option.value })
|
||||
.then(() => {
|
||||
setSearchResults((result) =>
|
||||
result
|
||||
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
|
||||
: result,
|
||||
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -45,7 +45,6 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
// until MCP elicitations carry session ownership.
|
||||
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
|
||||
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
|
||||
|
||||
type LocationData = {
|
||||
info?: LocationGetOutput
|
||||
@@ -62,7 +61,7 @@ type LocationData = {
|
||||
websearch?: WebSearchProvider[]
|
||||
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
||||
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
||||
shell?: Record<string, ShellWithLocation>
|
||||
shell?: Record<string, ShellInfo>
|
||||
skill?: SkillInfo[]
|
||||
}
|
||||
|
||||
@@ -852,10 +851,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "shell.created":
|
||||
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
|
||||
...data,
|
||||
shell: {
|
||||
...data?.shell,
|
||||
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
|
||||
},
|
||||
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
|
||||
}))
|
||||
break
|
||||
case "shell.exited":
|
||||
@@ -1110,18 +1106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
shell: Object.fromEntries(
|
||||
response.data.map((info) => [
|
||||
info.id,
|
||||
{
|
||||
...info,
|
||||
location: {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useData } from "./data"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../util/session"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useConfig } from "../config"
|
||||
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
|
||||
function state() {
|
||||
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
||||
return store.global
|
||||
if (config.tabs?.scope === "global") return store.global
|
||||
return store.cwd[paths.cwd] ?? fallback
|
||||
}
|
||||
|
||||
function update(mutation: (draft: TabsState) => void) {
|
||||
const scope = config.tabs?.scope ?? "global"
|
||||
const scope = config.tabs?.scope ?? "cwd"
|
||||
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
||||
() => {},
|
||||
)
|
||||
@@ -116,8 +116,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const sessionID = root(route.data.sessionID)
|
||||
history = recordSessionTabHistory(history, sessionID)
|
||||
const session = data.session.get(sessionID)
|
||||
const title =
|
||||
session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? withTimestampedFallback(session) : undefined)
|
||||
const title = session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? sessionTitle(session) : undefined)
|
||||
const tabs = openSessionTab(state().tabs, { sessionID, title })
|
||||
if (tabs === state().tabs && !state().unread[sessionID]) return
|
||||
update((draft) => {
|
||||
@@ -131,10 +130,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
const session = data.session.get(sessionID)
|
||||
return openSessionTab(tabs, {
|
||||
sessionID,
|
||||
title: session ? withTimestampedFallback(session) : tab.title,
|
||||
})
|
||||
return openSessionTab(tabs, { sessionID, title: session ? sessionTitle(session) : tab.title })
|
||||
}, [])
|
||||
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||
import path from "path"
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import { isDefaultTitle } from "../util/session"
|
||||
import { monoSnapshot } from "./mono"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
@@ -105,7 +105,7 @@ function shutdown(renderer: CliRenderer): void {
|
||||
}
|
||||
|
||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !isFallbackTitle(title)) {
|
||||
if (title && !isDefaultTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
@@ -176,7 +176,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||
const setTitle = (title?: string) => {
|
||||
if (input.host.platform !== "linux") return
|
||||
if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
||||
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
||||
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
|
||||
}
|
||||
setTitle(input.sessionTitle)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useData } from "../../../context/data"
|
||||
import { useLocation } from "../../../context/location"
|
||||
import { useClient } from "../../../context/client"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
@@ -9,14 +10,13 @@ import { useComposerTab } from "./index"
|
||||
|
||||
export function ShellTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const client = useClient()
|
||||
const theme = useTheme()
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
|
||||
)
|
||||
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
|
||||
|
||||
const [store, setStore] = createStore({ selected: 0 })
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -83,9 +83,10 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry) return
|
||||
const ref = location.current
|
||||
void client.api.shell.remove({
|
||||
id: entry.id,
|
||||
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
|
||||
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTheme } from "../../../context/theme"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./index"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../../../util/session"
|
||||
|
||||
interface SubagentEntry {
|
||||
sessionID: string
|
||||
@@ -39,7 +39,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
if (current.parentID) {
|
||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
||||
for (const sibling of siblings) {
|
||||
const title = withTimestampedFallback(sibling)
|
||||
const title = sessionTitle(sibling)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const agent = sibling.agent
|
||||
? Locale.titlecase(sibling.agent)
|
||||
@@ -58,7 +58,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
} else {
|
||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
||||
for (const child of children) {
|
||||
const title = withTimestampedFallback(child)
|
||||
const title = sessionTitle(child)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const agent = child.agent
|
||||
? Locale.titlecase(child.agent)
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useClient } from "../../context/client"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogConfirm } from "../../ui/dialog-confirm"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
@@ -92,7 +93,7 @@ import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../../util/session"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -143,8 +144,8 @@ export function Session() {
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
const messages = () => data.session.message.list(route.sessionID)
|
||||
const location = createMemo(() => session()?.location)
|
||||
const currentLocation = useLocation()
|
||||
const location = createMemo(() => session()?.location ?? currentLocation.ref)
|
||||
|
||||
createEffect(() => currentLocation.set(location()))
|
||||
|
||||
@@ -522,6 +523,32 @@ export function Session() {
|
||||
slash: { name: "rename" },
|
||||
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
|
||||
},
|
||||
{
|
||||
title: "Delete session",
|
||||
id: "session.delete",
|
||||
group: "Session",
|
||||
slash: { name: "delete" },
|
||||
run: async () => {
|
||||
const current = session()
|
||||
if (!current) return
|
||||
const confirmed = await DialogConfirm.show(
|
||||
dialog,
|
||||
"Delete Session",
|
||||
`Delete "${current.title}"? This action cannot be undone.`,
|
||||
)
|
||||
if (confirmed !== true) return
|
||||
const error = await client.api.session.remove({ sessionID: route.sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Jump to message",
|
||||
id: "session.timeline",
|
||||
@@ -3308,7 +3335,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
|
||||
})
|
||||
return [`## Assistant\n\n${content.join("\n\n")}`]
|
||||
})
|
||||
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
|
||||
return `# ${sessionTitle(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
|
||||
}
|
||||
|
||||
export function parseApplyPatchFiles(value: unknown) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../../util/session"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{withTimestampedFallback(session()!)}</b>
|
||||
<b>{sessionTitle(session()!)}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Locale } from "./locale"
|
||||
|
||||
export function isDefaultTitle(title?: string) {
|
||||
return (
|
||||
title === undefined || /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionTitle(session: Pick<SessionInfo, "parentID" | "time" | "title">) {
|
||||
return (
|
||||
session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`
|
||||
)
|
||||
}
|
||||
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
|
||||
@@ -9,11 +9,7 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
@@ -1786,19 +1782,12 @@ test("refreshes references after updates", async () => {
|
||||
test("keeps shell state scoped to location", async () => {
|
||||
const events = createEventStream()
|
||||
const other = "/tmp/opencode/other"
|
||||
const workspace = "ws_other"
|
||||
let removed: URL | undefined
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
|
||||
removed = url
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/shell") return
|
||||
const requestDirectory = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: {
|
||||
directory: requestDirectory ?? directory,
|
||||
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||
project: { id: "proj_test", directory: requestDirectory ?? directory },
|
||||
},
|
||||
data: [
|
||||
@@ -1809,7 +1798,7 @@ test("keeps shell state scoped to location", async () => {
|
||||
cwd: requestDirectory ?? directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/opencode-shell",
|
||||
metadata: { sessionID: "ses_shared" },
|
||||
metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" },
|
||||
time: { started: 1 },
|
||||
},
|
||||
],
|
||||
@@ -1819,15 +1808,7 @@ test("keeps shell state scoped to location", async () => {
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return (
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</RouteProvider>
|
||||
)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
@@ -1841,31 +1822,19 @@ test("keeps shell state scoped to location", async () => {
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
||||
await data.shell.sync({ directory: other, workspaceID: workspace })
|
||||
await data.shell.sync({ directory: other })
|
||||
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
|
||||
["sh_default", directory],
|
||||
["sh_other", other],
|
||||
])
|
||||
|
||||
await app.waitForFrame((frame) => frame.includes("pnpm dev"))
|
||||
app.mockInput.pressArrow("down")
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
await wait(() => removed !== undefined)
|
||||
expect(removed?.searchParams.get("location[directory]")).toBe(other)
|
||||
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
|
||||
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||
|
||||
events.emit({
|
||||
id: "evt_shell_created",
|
||||
created: 0,
|
||||
type: "shell.created",
|
||||
location: { directory: other, workspaceID: workspace },
|
||||
location: { directory: other },
|
||||
data: {
|
||||
info: {
|
||||
id: "sh_live_other",
|
||||
@@ -1874,18 +1843,13 @@ test("keeps shell state scoped to location", async () => {
|
||||
cwd: other,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/opencode-shell-live",
|
||||
metadata: { sessionID: "ses_shared" },
|
||||
metadata: { sessionID: "ses_other" },
|
||||
time: { started: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() =>
|
||||
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
|
||||
)
|
||||
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory).toBe(
|
||||
other,
|
||||
)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdtempSync, rmSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogOpen } from "../../../src/component/dialog-open"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../../src/context/session-tabs"
|
||||
import { StorageProvider } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("selecting an unhydrated session preserves its location", async () => {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
|
||||
const events = createEventStream()
|
||||
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/session") return
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_remote",
|
||||
projectID: "proj_remote",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Remote session",
|
||||
location: remote,
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
const dialog = useDialog()
|
||||
route = useRoute()
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
onMount(() => dialog.replace(() => <DialogOpen />))
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("Remote session"))
|
||||
expect(data.session.get("ses_remote")).toBeUndefined()
|
||||
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitFor(() => route.data.type === "session")
|
||||
|
||||
expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" })
|
||||
expect(location.ref).toEqual(remote)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -64,7 +64,6 @@ async function renderSessionTabs(initialSessionID: string) {
|
||||
return {
|
||||
tabs,
|
||||
route,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
destroy() {
|
||||
app.renderer.destroy()
|
||||
@@ -73,21 +72,6 @@ async function renderSessionTabs(initialSessionID: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("stores session tabs globally by default", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
global: { tabs: [{ sessionID: "first" }], unread: {} },
|
||||
cwd: {},
|
||||
})
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("user prompt admissions pulse an already-busy background tab", async () => {
|
||||
const setup = await renderSessionTabs("background")
|
||||
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
|
||||
@@ -122,7 +106,9 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
|
||||
expect(setup.tabs.status("background").promptPulse).toBe(0)
|
||||
|
||||
setup.emit(admitted("background", "msg_1"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
|
||||
await wait(
|
||||
() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy,
|
||||
)
|
||||
|
||||
setup.emit(admitted("background", "msg_2"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 2)
|
||||
@@ -158,7 +144,9 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
||||
await wait(() => setup.tabs.newTab())
|
||||
setup.route.navigate({ type: "session", sessionID: "third" })
|
||||
expect(setup.tabs.newTab()).toBe(true)
|
||||
await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
|
||||
await wait(
|
||||
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
|
||||
)
|
||||
|
||||
expect(setup.tabs.newTab()).toBe(false)
|
||||
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { lastAssistantWithUsage } from "../../src/util/session"
|
||||
import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
@@ -13,6 +13,22 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
})
|
||||
|
||||
describe("util.session", () => {
|
||||
test("recognizes generated parent and child titles", () => {
|
||||
expect(isDefaultTitle(undefined)).toBeTrue()
|
||||
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("New session - custom")).toBeFalse()
|
||||
})
|
||||
|
||||
test("derives display-only titles for untitled sessions", () => {
|
||||
expect(sessionTitle({ title: undefined, time: { created: 0, updated: 0 } })).toBe(
|
||||
"New session - 1970-01-01T00:00:00.000Z",
|
||||
)
|
||||
expect(sessionTitle({ title: undefined, parentID: "ses_parent", time: { created: 0, updated: 0 } })).toBe(
|
||||
"Child session - 1970-01-01T00:00:00.000Z",
|
||||
)
|
||||
})
|
||||
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export * as SessionTitleFallback from "./session-title-fallback.js"
|
||||
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
interface Info {
|
||||
readonly title?: string
|
||||
readonly parentID?: string
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Supplies the timestamped title required by compatibility surfaces. */
|
||||
export function withTimestampedFallback(info: Info) {
|
||||
return info.title ?? fallback(info)
|
||||
}
|
||||
|
||||
/** Supplies a compact human label and collapses historical timestamped fallbacks. */
|
||||
export function displayLabel(info: Pick<Info, "title" | "parentID">) {
|
||||
if (!info.title) return info.parentID ? "Child session" : "New session"
|
||||
return info.title.match(pattern)?.[1] ?? info.title
|
||||
}
|
||||
|
||||
/** Recognizes missing and historical root or child fallback titles. */
|
||||
export function isFallbackTitle(title?: string) {
|
||||
return title === undefined || pattern.test(title)
|
||||
}
|
||||
|
||||
/** Recognizes a missing title or the exact root fallback for this session. */
|
||||
export function isExactRootFallback(info: Pick<Info, "title" | "time">) {
|
||||
return info.title === undefined || info.title === fallback({ time: info.time })
|
||||
}
|
||||
|
||||
function fallback(info: Pick<Info, "parentID" | "time">) {
|
||||
return `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}`
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { SessionTitleFallback } from "../src/session-title-fallback.js"
|
||||
|
||||
const root = { title: undefined, time: { created: 0 } }
|
||||
const child = { ...root, parentID: "ses_parent" }
|
||||
|
||||
describe("SessionTitleFallback", () => {
|
||||
test("supplies timestamped compatibility titles", () => {
|
||||
expect(SessionTitleFallback.withTimestampedFallback(root)).toBe("New session - 1970-01-01T00:00:00.000Z")
|
||||
expect(SessionTitleFallback.withTimestampedFallback(child)).toBe("Child session - 1970-01-01T00:00:00.000Z")
|
||||
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "Generated title" })).toBe("Generated title")
|
||||
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "" })).toBe("")
|
||||
})
|
||||
|
||||
test("supplies compact display labels", () => {
|
||||
expect(SessionTitleFallback.displayLabel(root)).toBe("New session")
|
||||
expect(SessionTitleFallback.displayLabel(child)).toBe("Child session")
|
||||
expect(SessionTitleFallback.displayLabel({ title: "", parentID: "ses_parent" })).toBe("Child session")
|
||||
expect(SessionTitleFallback.displayLabel({ title: "New session - 2026-07-30T18:45:03.662Z" })).toBe("New session")
|
||||
expect(SessionTitleFallback.displayLabel({ title: "Child session - 2026-07-30T18:45:03.662Z" })).toBe(
|
||||
"Child session",
|
||||
)
|
||||
expect(SessionTitleFallback.displayLabel({ title: "Generated title" })).toBe("Generated title")
|
||||
})
|
||||
|
||||
test("recognizes historical fallback titles", () => {
|
||||
expect(SessionTitleFallback.isFallbackTitle(undefined)).toBeTrue()
|
||||
expect(SessionTitleFallback.isFallbackTitle("New session - 2026-07-30T18:45:03.662Z")).toBeTrue()
|
||||
expect(SessionTitleFallback.isFallbackTitle("Child session - 2026-07-30T18:45:03.662Z")).toBeTrue()
|
||||
expect(SessionTitleFallback.isFallbackTitle("")).toBeFalse()
|
||||
expect(SessionTitleFallback.isFallbackTitle("New session - custom")).toBeFalse()
|
||||
})
|
||||
|
||||
test("recognizes only the fallback associated with a session", () => {
|
||||
expect(SessionTitleFallback.isExactRootFallback(root)).toBeTrue()
|
||||
expect(
|
||||
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 1970-01-01T00:00:00.000Z" }),
|
||||
).toBeTrue()
|
||||
expect(
|
||||
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 2099-01-01T00:00:00.000Z" }),
|
||||
).toBeFalse()
|
||||
expect(
|
||||
SessionTitleFallback.isExactRootFallback({
|
||||
...child,
|
||||
title: "Child session - 1970-01-01T00:00:00.000Z",
|
||||
}),
|
||||
).toBeFalse()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user