diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index c1954eec5..260fc4a71 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -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() + 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 [] } @@ -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"), @@ -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"), @@ -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) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 69a6727ab..3745c8bea 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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] @@ -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) @@ -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(/[\s\S]*?<\/think>\s*/g, "") @@ -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: { @@ -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). @@ -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() diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 4c18e6647..2ba5f9f50 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -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() }, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aa..5e805b7ac 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -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 - " + 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, +)