Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,17 +646,28 @@ function SessionChatsDropdown() {
// Active sessions — only computed when the flyout is open to avoid
// triggering reactive subscriptions (serverSync().child pins the directory
// and can cascade re-renders to the parent Portal).
const directory = createMemo(() => {
if (!open()) return ""
return serverSync().data.path.directory || ""
})
// Source from ALL project directories (same as dashboard) — not just the
// server's cwd, which may not be where sessions live (amicode#138).
const activeSessions = createMemo(() => {
if (!open()) return []
try {
const dir = directory()
if (!dir) return []
const [store] = serverSync().child(dir, { bootstrap: false })
return sortedRootSessions(store, Date.now())
const conn = server.current
if (!conn) return []
const ctx = globalCtx.ensureServerCtx(conn)
if (!ctx) return []
const projects = ctx.projects.list()
const directories = projects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])])
const seen = new Set<string>()
const sessions: Session[] = []
for (const dir of directories) {
const [store] = ctx.sync.child(dir, { bootstrap: false })
for (const session of sortedRootSessions(store, Date.now())) {
if (seen.has(session.id)) continue
seen.add(session.id)
sessions.push(session)
}
}
return sessions.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
} catch {
return []
}
Expand Down Expand Up @@ -745,8 +756,8 @@ function SessionChatsDropdown() {
time: { archived: Date.now() },
})
setArchivedSessions((prev) => [session, ...prev])
// Reload active sessions
await serverSync().project.loadSessions(directory(), { limit: 64 })
// Reload active sessions for this session's directory
await serverSync().project.loadSessions(session.directory, { limit: 64 })
} catch (cause) {
showToast({
title: language.t("common.requestFailed"),
Expand All @@ -765,7 +776,7 @@ function SessionChatsDropdown() {
time: { archived: null },
})
setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id))
await serverSync().project.loadSessions(directory(), { limit: 64 })
await serverSync().project.loadSessions(session.directory, { limit: 64 })
} catch (cause) {
showToast({
title: language.t("common.requestFailed"),
Expand Down Expand Up @@ -793,7 +804,7 @@ function SessionChatsDropdown() {
}

function handleNewChat() {
const dir = directory()
const dir = serverSync().data.path.directory || ""
void tabs.newDraft({ server: server.key, directory: dir })
setOpen(false)
}
Expand Down
24 changes: 17 additions & 7 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ const layer = Layer.effect(
m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)
const idx = input.history.findIndex(real)
if (idx === -1) return
if (input.history.filter(real).length !== 1) return
if (input.history.filter(real).length < 1) return

const context = input.history.slice(0, idx + 1)
const firstUser = context[idx]
Expand All @@ -217,10 +217,12 @@ const layer = Layer.effect(

const ag = yield* agents.get("title")
if (!ag) return
// Use the session's proven model (it just completed the main response).
// getSmallModel can resolve catalog models the account can't actually reach
// (e.g. cross-region Haiku on Bedrock), causing silent hangs.
const mdl = ag.model
? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
: ((yield* provider.getSmallModel(input.providerID)) ??
(yield* provider.getModel(input.providerID, input.modelID)))
: yield* provider.getModel(input.providerID, input.modelID)
const msgs = onlySubtasks
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
: yield* MessageV2.toModelMessagesEffect(context, mdl)
Expand All @@ -240,7 +242,6 @@ const layer = Layer.effect(
Stream.filter(LLMEvent.is.textDelta),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
)
const cleaned = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
Expand All @@ -251,7 +252,7 @@ const layer = Layer.effect(
const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
yield* sessions
.setTitle({ sessionID: input.session.id, title: t })
.pipe(Effect.catchCause((cause) => Effect.logError("failed to generate title", { error: Cause.squash(cause) })))
.pipe(Effect.catchCause((cause) => Effect.logError("failed to set title", { error: Cause.squash(cause) })))
})

const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
Expand Down Expand Up @@ -1089,6 +1090,10 @@ const layer = Layer.effect(
const ctx = yield* InstanceState.context
let structured: unknown
let step = 0
// Title generation: decoupled from `step` so that silent-turn and
// prose-question guards (which pre-increment step) don't permanently
// prevent it. Retries up to 3 times per session; stops on success.
let titleAttempts = 0
// Amico interview guard: assistant message IDs already nudged to re-ask
// via the `question` tool, so a stubborn turn is nudged at most once
// (the Assistant info schema has no metadata field to persist this on).
Expand Down Expand Up @@ -1282,13 +1287,18 @@ const layer = Layer.effect(
}

step++
if (step === 1)
if (Session.isDefaultTitle(session.title) && titleAttempts < 3) {
titleAttempts++
yield* title({
session,
modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID,
history: msgs,
}).pipe(Effect.ignore, Effect.forkIn(scope))
}).pipe(
Effect.ignoreCause({ log: "Warn", message: "title generation failed" }),
Effect.forkIn(scope),
)
}

const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
const task = tasks.pop()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ const layer: Layer.Layer<
})

const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) {
const current = yield* get(input.sessionID)
const current = yield* get(input.sessionID).pipe(Effect.orDie)
const next = {
...current,
time: { ...current.time, archived: input.time, updated: Date.now() },
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2400,3 +2400,43 @@ noLLMServer.instance(
}),
30_000,
)

// Title generation

it.instance(
"title generation fires on new session with default title",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
// Create session WITHOUT an explicit title — gets default "New session - <ISO>"
const chat = yield* sessions.create({})
expect(Session.isDefaultTitle(chat.title)).toBe(true)

// Queue the main conversation response
yield* llm.text("hello back")

// Send a prompt — this triggers the loop, which fires title generation
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
parts: [{ type: "text", text: "hello" }],
})

// Title generation is forked — poll until the session title changes
yield* pollWithTimeout(
Effect.gen(function* () {
const updated = yield* sessions.get(chat.id).pipe(Effect.orDie)
if (!Session.isDefaultTitle(updated.title)) return updated.title
return undefined
}),
"title was never generated",
"5 seconds",
)

const final = yield* sessions.get(chat.id).pipe(Effect.orDie)
expect(final.title).toBe("E2E Title")
}),
30_000,
)
Loading