Skip to content

Commit ef2e48e

Browse files
committed
Release v0.0.63
## What's New **Features** - **Fork Sub-Chat** — Fork a new sub-chat from any assistant message - **Shared Terminals** — Terminals are now shared across local-mode workspaces **Improvements & Fixes** - **Scroll-to-Bottom** — Restored auto-scroll on chat open and tab switch - **Early Access Updates** — No longer offers downgrade to older beta versions - **Queue Attachments** — Show typed attachment labels instead of generic "files" - **Traffic Lights** — Fixed visibility when agents sidebar is closed on app reopen
1 parent 64fe2c6 commit ef2e48e

25 files changed

+791
-212
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.62",
3+
"version": "0.0.63",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/main/lib/auto-updater.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,18 @@ function getChannelPrefPath(): string {
4242
}
4343

4444
function getSavedChannel(): UpdateChannel {
45-
// Beta channel disabled until beta-mac.yml is published to CDN.
46-
// Always use "latest" to prevent 404 errors for users who toggled Early Access.
45+
try {
46+
const prefPath = getChannelPrefPath()
47+
if (existsSync(prefPath)) {
48+
const data = JSON.parse(readFileSync(prefPath, "utf-8"))
49+
if (data.channel === "beta" || data.channel === "latest") {
50+
return data.channel
51+
}
52+
}
53+
} catch {
54+
// Ignore read errors, fall back to default
55+
}
4756
return "latest"
48-
// try {
49-
// const prefPath = getChannelPrefPath()
50-
// if (existsSync(prefPath)) {
51-
// const data = JSON.parse(readFileSync(prefPath, "utf-8"))
52-
// if (data.channel === "beta" || data.channel === "latest") {
53-
// return data.channel
54-
// }
55-
// }
56-
// } catch {
57-
// // Ignore read errors, fall back to default
58-
// }
59-
// return "latest"
6057
}
6158

6259
function saveChannel(channel: UpdateChannel): void {
@@ -98,6 +95,9 @@ export async function initAutoUpdater(getWindows: () => BrowserWindow[]) {
9895
// Set update channel from saved preference
9996
const savedChannel = getSavedChannel()
10097
autoUpdater.channel = savedChannel
98+
// electron-updater auto-sets allowDowngrade=true when channel is changed.
99+
// We never want to offer a downgrade (e.g. beta 0.0.60-beta.5 when stable is 0.0.62).
100+
autoUpdater.allowDowngrade = false
101101
log.info(`[AutoUpdater] Using update channel: ${savedChannel}`)
102102

103103
// Configure feed URL to point to R2 CDN
@@ -253,6 +253,9 @@ function registerIpcHandlers() {
253253
}
254254
log.info(`[AutoUpdater] Switching update channel to: ${channel}`)
255255
autoUpdater.channel = channel
256+
// electron-updater auto-sets allowDowngrade=true when channel is changed.
257+
// We never want to offer a downgrade — only show updates newer than current version.
258+
autoUpdater.allowDowngrade = false
256259
saveChannel(channel)
257260
// Check for updates immediately with new channel
258261
if (app.isPackaged) {

src/main/lib/terminal/manager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,23 @@ export class TerminalManager extends EventEmitter {
336336
).length
337337
}
338338

339+
/**
340+
* Get all alive sessions for a given scope key.
341+
* Used by new workspaces to discover shared terminals.
342+
*/
343+
getSessionsByScopeKey(
344+
scopeKey: string,
345+
): Array<{ paneId: string; cwd: string; lastActive: number }> {
346+
return Array.from(this.sessions.values())
347+
.filter((session) => session.scopeKey === scopeKey && session.isAlive)
348+
.map((session) => ({
349+
paneId: session.paneId,
350+
cwd: session.cwd,
351+
lastActive: session.lastActive,
352+
}))
353+
}
354+
355+
339356
/**
340357
* Send a newline to all terminals in a workspace to refresh their prompts.
341358
* Useful after switching branches to update the branch name in prompts.

src/main/lib/terminal/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export async function createSession(
156156
pty: ptyProcess,
157157
paneId,
158158
workspaceId: workspaceId || "",
159+
scopeKey: params.scopeKey || workspaceId || "",
159160
cwd: workingDir,
160161
cols: terminalCols,
161162
rows: terminalRows,

src/main/lib/terminal/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export interface TerminalSession {
44
pty: pty.IPty
55
paneId: string
66
workspaceId: string
7+
/** Terminal scope key: "path:<dir>" for shared (local mode) or "ws:<chatId>" for isolated (worktree mode) */
8+
scopeKey: string
79
cwd: string
810
cols: number
911
rows: number
@@ -38,6 +40,8 @@ export interface CreateSessionParams {
3840
paneId: string
3941
tabId?: string
4042
workspaceId?: string
43+
/** Terminal scope key: "path:<dir>" for shared (local mode) or "ws:<chatId>" for isolated (worktree mode) */
44+
scopeKey?: string
4145
workspaceName?: string
4246
workspacePath?: string
4347
rootPath?: string

src/main/lib/trpc/routers/chats.ts

Lines changed: 190 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -514,16 +514,21 @@ export const chatsRouter = router({
514514
// Track workspace archived
515515
trackWorkspaceArchived(input.id)
516516

517-
// Kill terminal processes in background (don't await)
518-
terminalManager.killByWorkspaceId(input.id).then((killResult) => {
519-
if (killResult.killed > 0) {
520-
console.log(
521-
`[chats.archive] Killed ${killResult.killed} terminal session(s) for workspace ${input.id}`,
522-
)
523-
}
524-
}).catch((error) => {
525-
console.error(`[chats.archive] Error killing processes:`, error)
526-
})
517+
// Kill terminal processes only for worktree-mode workspaces.
518+
// Local-mode terminals are shared across workspaces on the same project path,
519+
// so they should not be killed when a single workspace is archived.
520+
const isLocalMode = !chat?.branch
521+
if (!isLocalMode) {
522+
terminalManager.killByWorkspaceId(input.id).then((killResult) => {
523+
if (killResult.killed > 0) {
524+
console.log(
525+
`[chats.archive] Killed ${killResult.killed} terminal session(s) for workspace ${input.id}`,
526+
)
527+
}
528+
}).catch((error) => {
529+
console.error(`[chats.archive] Error killing processes:`, error)
530+
})
531+
}
527532

528533
// Optionally delete worktree in background (don't await)
529534
if (input.deleteWorktree && chat?.worktreePath && chat?.branch) {
@@ -588,6 +593,14 @@ export const chatsRouter = router({
588593
const db = getDatabase()
589594
if (input.chatIds.length === 0) return []
590595

596+
// Identify worktree-mode workspaces before archiving (for terminal cleanup)
597+
const worktreeChats = db
598+
.select({ id: chats.id, branch: chats.branch })
599+
.from(chats)
600+
.where(inArray(chats.id, input.chatIds))
601+
.all()
602+
.filter((c) => c.branch != null)
603+
591604
// Archive immediately (optimistic)
592605
const result = db
593606
.update(chats)
@@ -596,19 +609,23 @@ export const chatsRouter = router({
596609
.returning()
597610
.all()
598611

599-
// Kill terminal processes for all workspaces in background (don't await)
600-
Promise.all(
601-
input.chatIds.map((id) => terminalManager.killByWorkspaceId(id)),
602-
).then((killResults) => {
603-
const totalKilled = killResults.reduce((sum, r) => sum + r.killed, 0)
604-
if (totalKilled > 0) {
605-
console.log(
606-
`[chats.archiveBatch] Killed ${totalKilled} terminal session(s) for ${input.chatIds.length} workspace(s)`,
607-
)
608-
}
609-
}).catch((error) => {
610-
console.error(`[chats.archiveBatch] Error killing processes:`, error)
611-
})
612+
// Kill terminal processes only for worktree-mode workspaces.
613+
// Local-mode terminals are shared and should not be killed.
614+
615+
if (worktreeChats.length > 0) {
616+
Promise.all(
617+
worktreeChats.map((c) => terminalManager.killByWorkspaceId(c.id)),
618+
).then((killResults) => {
619+
const totalKilled = killResults.reduce((sum, r) => sum + r.killed, 0)
620+
if (totalKilled > 0) {
621+
console.log(
622+
`[chats.archiveBatch] Killed ${totalKilled} terminal session(s) for ${worktreeChats.length} worktree workspace(s)`,
623+
)
624+
}
625+
}).catch((error) => {
626+
console.error(`[chats.archiveBatch] Error killing processes:`, error)
627+
})
628+
}
612629

613630
return result
614631
}),
@@ -639,6 +656,14 @@ export const chatsRouter = router({
639656
}
640657
}
641658

659+
// Kill terminal processes for worktree-mode workspaces.
660+
// Local-mode terminals are shared and should not be killed on delete.
661+
if (chat?.branch) {
662+
terminalManager.killByWorkspaceId(input.id).catch((error) => {
663+
console.error(`[chats.delete] Error killing processes:`, error)
664+
})
665+
}
666+
642667
// Track workspace deleted
643668
trackWorkspaceDeleted(input.id)
644669

@@ -710,6 +735,148 @@ export const chatsRouter = router({
710735
.get()
711736
}),
712737

738+
/**
739+
* Fork a sub-chat from a specific message, preserving SDK session context.
740+
* Creates a new sub-chat with messages up to the target message,
741+
* copies the .jsonl session file, and marks it for forkSession resume.
742+
*/
743+
forkSubChat: publicProcedure
744+
.input(
745+
z.object({
746+
subChatId: z.string(),
747+
messageId: z.string(),
748+
name: z.string().optional(),
749+
}),
750+
)
751+
.mutation(async ({ input }) => {
752+
const db = getDatabase()
753+
754+
// 1. Get the source sub-chat
755+
const sourceSubChat = db
756+
.select()
757+
.from(subChats)
758+
.where(eq(subChats.id, input.subChatId))
759+
.get()
760+
if (!sourceSubChat) throw new Error("Source sub-chat not found")
761+
762+
// 2. Parse messages and find the cutoff point
763+
const allMessages = JSON.parse(sourceSubChat.messages || "[]")
764+
const cutoffIndex = allMessages.findIndex(
765+
(m: any) => m.id === input.messageId,
766+
)
767+
if (cutoffIndex === -1) throw new Error("Message not found")
768+
769+
// 3. Slice messages up to and including the target
770+
const messagesToFork = allMessages.slice(0, cutoffIndex + 1)
771+
772+
// 4. Find sdkMessageUuid of last assistant message (for resumeSessionAt)
773+
const lastAssistant = [...messagesToFork]
774+
.reverse()
775+
.find((m: any) => m.role === "assistant")
776+
const forkAtSdkUuid = lastAssistant?.metadata?.sdkMessageUuid || null
777+
778+
// 5. Generate new IDs for all messages + set shouldForkResume on last assistant
779+
const forkedMessages = messagesToFork.map((msg: any, i: number) => ({
780+
...msg,
781+
id: `fork-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 7)}`,
782+
metadata: {
783+
...msg.metadata,
784+
shouldResume: undefined,
785+
...(msg === lastAssistant &&
786+
forkAtSdkUuid && {
787+
shouldForkResume: true,
788+
}),
789+
},
790+
}))
791+
792+
// 6. Generate fork name: [N] originalName
793+
let forkName = input.name
794+
if (!forkName) {
795+
// Strip existing [N] prefix from source name to get base name
796+
const sourceName = sourceSubChat.name || "Chat"
797+
const baseName = sourceName.replace(/^\[\d+\]\s*/, "")
798+
799+
// Find highest [N] among all sibling sub-chats
800+
const siblings = db
801+
.select({ name: subChats.name })
802+
.from(subChats)
803+
.where(eq(subChats.chatId, sourceSubChat.chatId))
804+
.all()
805+
806+
let maxN = 0
807+
for (const s of siblings) {
808+
const match = s.name?.match(/^\[(\d+)\]/)
809+
if (match) {
810+
maxN = Math.max(maxN, parseInt(match[1], 10))
811+
}
812+
}
813+
814+
forkName = `[${maxN + 1}] ${baseName}`
815+
}
816+
817+
// 7. Insert new sub-chat with sessionId from original (needed for resume)
818+
const newSubChat = db
819+
.insert(subChats)
820+
.values({
821+
chatId: sourceSubChat.chatId,
822+
name: forkName,
823+
mode: sourceSubChat.mode,
824+
messages: JSON.stringify(forkedMessages),
825+
sessionId: sourceSubChat.sessionId,
826+
})
827+
.returning()
828+
.get()
829+
830+
// 8. Copy .jsonl session files to the new isolated config dir
831+
if (sourceSubChat.sessionId) {
832+
try {
833+
const { app } = await import("electron")
834+
const userDataPath = app.getPath("userData")
835+
const sourceDir = path.join(
836+
userDataPath,
837+
"claude-sessions",
838+
input.subChatId,
839+
"projects",
840+
)
841+
const targetDir = path.join(
842+
userDataPath,
843+
"claude-sessions",
844+
newSubChat.id,
845+
"projects",
846+
)
847+
848+
const sourceDirExists = await fs
849+
.stat(sourceDir)
850+
.then(() => true)
851+
.catch(() => false)
852+
853+
if (sourceDirExists) {
854+
await fs.cp(sourceDir, targetDir, { recursive: true })
855+
}
856+
} catch (err) {
857+
console.warn("[forkSubChat] Failed to copy session files:", err)
858+
// Clear shouldForkResume since there's no .jsonl to fork from
859+
for (const m of forkedMessages) {
860+
if (m.metadata?.shouldForkResume) {
861+
delete m.metadata.shouldForkResume
862+
}
863+
}
864+
db.update(subChats)
865+
.set({ messages: JSON.stringify(forkedMessages) })
866+
.where(eq(subChats.id, newSubChat.id))
867+
.run()
868+
}
869+
}
870+
871+
console.log("[forkSubChat] Created", { id: newSubChat.id, name: forkName, messages: forkedMessages.length })
872+
873+
return {
874+
subChat: newSubChat,
875+
messageCount: forkedMessages.length,
876+
forkAtSdkUuid,
877+
}
878+
}),
879+
713880
/**
714881
* Update sub-chat messages
715882
*/

0 commit comments

Comments
 (0)