Skip to content

Commit a2b0184

Browse files
committed
Release v0.0.60-beta.1
## What's New ### Features - **Split View** — Side-by-side sub-chats with up to 6 panes for parallel work with AI agents ### Improvements & Fixes - **Smart file click** — Click files in git activity badges to open them directly - **Split view polish** — Address various split view review issues
1 parent 0d773a1 commit a2b0184

21 files changed

Lines changed: 1144 additions & 197 deletions

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.59",
3+
"version": "0.0.60-beta.1",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/main/lib/claude/transform.ts

Lines changed: 35 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export function createTransformer(options?: { isUsingOllama?: boolean }) {
3636
let inThinkingBlock = false // Track if we're currently in a thinking block
3737
let thinkingJsonStarted = false // Track if we've sent the JSON prefix for thinking deltas
3838

39+
// Track usage from the last main assistant message (exclude sidechain/subagents).
40+
// This is used for accurate context window display in final metadata.
41+
let lastMainAssistantUsage: {
42+
input_tokens: number
43+
cache_read_input_tokens: number
44+
cache_creation_input_tokens: number
45+
output_tokens: number
46+
} | null = null
47+
3948
// Helper to create composite toolCallId: "parentId:childId" or just "childId"
4049
const makeCompositeId = (originalId: string, parentId: string | null): string => {
4150
if (parentId) return `${parentId}:${originalId}`
@@ -233,6 +242,17 @@ export function createTransformer(options?: { isUsingOllama?: boolean }) {
233242
}
234243
}
235244

245+
// Track per-turn usage from main assistant messages only.
246+
// Sidechain/subagent assistant messages have parent_tool_use_id set.
247+
if (msg.type === "assistant" && msg.message?.usage && msg.parent_tool_use_id == null) {
248+
lastMainAssistantUsage = {
249+
input_tokens: msg.message.usage.input_tokens ?? 0,
250+
cache_read_input_tokens: msg.message.usage.cache_read_input_tokens ?? 0,
251+
cache_creation_input_tokens: msg.message.usage.cache_creation_input_tokens ?? 0,
252+
output_tokens: msg.message.usage.output_tokens ?? 0,
253+
}
254+
}
255+
236256
// ===== ASSISTANT MESSAGE (complete, often with tool_use) =====
237257
// When streaming is enabled, text arrives via stream_event, not here
238258
if (msg.type === "assistant" && msg.message?.content) {
@@ -410,51 +430,25 @@ export function createTransformer(options?: { isUsingOllama?: boolean }) {
410430
yield* endTextBlock()
411431
yield* endToolInput()
412432

413-
const inputTokens = msg.usage?.input_tokens
414-
const outputTokens = msg.usage?.output_tokens
415-
416-
// Extract per-model usage from SDK (if available)
417-
const modelUsage = msg.modelUsage
418-
? Object.fromEntries(
419-
Object.entries(msg.modelUsage).map(([model, usage]: [string, any]) => [
420-
model,
421-
{
422-
inputTokens: usage.inputTokens || 0,
423-
outputTokens: usage.outputTokens || 0,
424-
cacheReadInputTokens: usage.cacheReadInputTokens || 0,
425-
cacheCreationInputTokens: usage.cacheCreationInputTokens || 0,
426-
costUSD: usage.costUSD || 0,
427-
},
428-
])
429-
)
430-
: undefined
431-
432-
// Fallback: if SDK didn't populate msg.usage, derive totals from modelUsage
433-
const fallbackInputTokens = msg.modelUsage
434-
? Object.values(msg.modelUsage).reduce(
435-
(sum: number, usage: any) => sum + (usage?.inputTokens || 0),
436-
0,
437-
)
438-
: undefined
439-
const fallbackOutputTokens = msg.modelUsage
440-
? Object.values(msg.modelUsage).reduce(
441-
(sum: number, usage: any) => sum + (usage?.outputTokens || 0),
442-
0,
443-
)
444-
: undefined
445-
446-
const resolvedInputTokens =
447-
inputTokens == null || (inputTokens === 0 && (fallbackInputTokens || 0) > 0)
448-
? fallbackInputTokens
449-
: inputTokens
450-
const resolvedOutputTokens =
451-
outputTokens == null || (outputTokens === 0 && (fallbackOutputTokens || 0) > 0)
452-
? fallbackOutputTokens
453-
: outputTokens
433+
const resultOutputTokens = msg.usage?.output_tokens
434+
const fallbackUsage = {
435+
input_tokens: msg.usage?.input_tokens ?? 0,
436+
cache_read_input_tokens: msg.usage?.cache_read_input_tokens ?? 0,
437+
cache_creation_input_tokens: msg.usage?.cache_creation_input_tokens ?? 0,
438+
output_tokens: resultOutputTokens ?? 0,
439+
}
440+
441+
// Prefer the last main assistant usage snapshot for context metrics.
442+
// Fallback to result usage when assistant usage is unavailable.
443+
const usage = lastMainAssistantUsage ?? fallbackUsage
454444

445+
const resolvedInputTokens = usage.input_tokens
446+
const resolvedOutputTokens = resultOutputTokens ?? usage.output_tokens
455447
const metadata: MessageMetadata = {
456448
sessionId: msg.session_id,
457449
inputTokens: resolvedInputTokens,
450+
cacheReadInputTokens: usage.cache_read_input_tokens,
451+
cacheCreationInputTokens: usage.cache_creation_input_tokens,
458452
outputTokens: resolvedOutputTokens,
459453
totalTokens:
460454
resolvedInputTokens != null && resolvedOutputTokens != null
@@ -465,8 +459,6 @@ export function createTransformer(options?: { isUsingOllama?: boolean }) {
465459
resultSubtype: msg.subtype || "success",
466460
// Include finalTextId for collapsing tools when there's a final response
467461
finalTextId: lastTextId || undefined,
468-
// Per-model usage breakdown
469-
modelUsage,
470462
}
471463
yield { type: "message-metadata", messageMetadata: metadata }
472464
yield { type: "finish-step" }

src/main/lib/claude/types.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,24 +68,16 @@ export type MCPServer = {
6868
error?: string
6969
}
7070

71-
export type ModelUsageEntry = {
72-
inputTokens: number
73-
outputTokens: number
74-
cacheReadInputTokens: number
75-
cacheCreationInputTokens: number
76-
costUSD: number
77-
}
78-
7971
export type MessageMetadata = {
8072
sessionId?: string
8173
sdkMessageUuid?: string // SDK's message UUID for resumeSessionAt (rollback support)
8274
inputTokens?: number
75+
cacheReadInputTokens?: number
76+
cacheCreationInputTokens?: number
8377
outputTokens?: number
8478
totalTokens?: number
8579
totalCostUsd?: number
8680
durationMs?: number
8781
resultSubtype?: string
8882
finalTextId?: string
89-
// Per-model usage breakdown from SDK (model name -> usage)
90-
modelUsage?: Record<string, ModelUsageEntry>
9183
}

src/main/windows/main.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ function registerIpcHandlers(): void {
196196
})
197197

198198
// New window - optionally open with specific chat/subchat
199-
ipcMain.handle("window:new", (_event, options?: { chatId?: string; subChatId?: string }) => {
199+
ipcMain.handle("window:new", (_event, options?: { chatId?: string; subChatId?: string; splitPaneIds?: string[] }) => {
200200
createWindow(options)
201201
})
202202

@@ -562,7 +562,7 @@ function getUseNativeFramePreference(): boolean {
562562
* @param options.chatId Open this chat in the new window
563563
* @param options.subChatId Open this sub-chat in the new window
564564
*/
565-
export function createWindow(options?: { chatId?: string; subChatId?: string }): BrowserWindow {
565+
export function createWindow(options?: { chatId?: string; subChatId?: string; splitPaneIds?: string[] }): BrowserWindow {
566566
// Register IPC handlers before creating first window
567567
registerIpcHandlers()
568568

@@ -697,6 +697,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }):
697697
params.set("windowId", windowId)
698698
if (options?.chatId) params.set("chatId", options.chatId)
699699
if (options?.subChatId) params.set("subChatId", options.subChatId)
700+
if (options?.splitPaneIds) params.set("splitPaneIds", JSON.stringify(options.splitPaneIds))
700701
}
701702

702703
if (devServerUrl) {

src/preload/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ contextBridge.exposeInMainWorld("desktopApi", {
107107
getZoom: () => ipcRenderer.invoke("window:get-zoom"),
108108

109109
// Multi-window
110-
newWindow: (options?: { chatId?: string; subChatId?: string }) => ipcRenderer.invoke("window:new", options),
110+
newWindow: (options?: { chatId?: string; subChatId?: string; splitPaneIds?: string[] }) => ipcRenderer.invoke("window:new", options),
111111
setWindowTitle: (title: string) => ipcRenderer.invoke("window:set-title", title),
112112

113113
// DevTools
@@ -307,7 +307,7 @@ export interface DesktopApi {
307307
zoomReset: () => Promise<void>
308308
getZoom: () => Promise<number>
309309
// Multi-window
310-
newWindow: (options?: { chatId?: string; subChatId?: string }) => Promise<void>
310+
newWindow: (options?: { chatId?: string; subChatId?: string; splitPaneIds?: string[] }) => Promise<void>
311311
setWindowTitle: (title: string) => Promise<void>
312312
toggleDevTools: () => Promise<void>
313313
unlockDevTools: () => Promise<void>

src/renderer/App.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,17 @@ function AppContent() {
5454
const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom)
5555
const { setActiveSubChat, addToOpenSubChats, setChatId } = useAgentSubChatStore()
5656

57-
// Apply initial window params (chatId/subChatId) when opening via "Open in new window"
57+
// Apply initial window params (chatId/subChatId/splitPaneIds) when opening via "Open in new window"
5858
useEffect(() => {
5959
const params = getInitialWindowParams()
6060
if (params.chatId) {
61-
console.log("[App] Opening chat from window params:", params.chatId, params.subChatId)
61+
console.log("[App] Opening chat from window params:", params.chatId, params.subChatId, params.splitPaneIds)
6262
setSelectedChatId(params.chatId)
6363
setChatId(params.chatId)
64-
if (params.subChatId) {
64+
if (params.splitPaneIds && params.splitPaneIds.length >= 2) {
65+
// Open all split panes in the new window
66+
useAgentSubChatStore.getState().initSplitFromWindow(params.splitPaneIds)
67+
} else if (params.subChatId) {
6568
addToOpenSubChats(params.subChatId)
6669
setActiveSubChat(params.subChatId)
6770
}

src/renderer/contexts/WindowContext.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export function getWindowId(): string {
6868
* Get initial window params (chatId, subChatId) passed when opening a new window.
6969
* These are one-time use - cleared from sessionStorage after first read.
7070
*/
71-
export function getInitialWindowParams(): { chatId?: string; subChatId?: string } {
71+
export function getInitialWindowParams(): { chatId?: string; subChatId?: string; splitPaneIds?: string[] } {
7272
// Check if already consumed
7373
const consumed = sessionStorage.getItem("windowParamsConsumed")
7474
if (consumed) return {}
@@ -77,21 +77,29 @@ export function getInitialWindowParams(): { chatId?: string; subChatId?: string
7777
const urlParams = new URLSearchParams(window.location.search)
7878
let chatId = urlParams.get("chatId")
7979
let subChatId = urlParams.get("subChatId")
80+
let rawSplitPaneIds = urlParams.get("splitPaneIds")
8081

8182
// Try hash params (production file:// URLs)
8283
if (!chatId && window.location.hash) {
8384
const hashParams = new URLSearchParams(window.location.hash.slice(1))
8485
chatId = hashParams.get("chatId")
8586
subChatId = hashParams.get("subChatId")
87+
rawSplitPaneIds = rawSplitPaneIds || hashParams.get("splitPaneIds")
8688
}
8789

8890
// Mark as consumed so we don't re-apply on hot reload
8991
if (chatId || subChatId) {
9092
sessionStorage.setItem("windowParamsConsumed", "true")
9193
}
9294

95+
let splitPaneIds: string[] | undefined
96+
if (rawSplitPaneIds) {
97+
try { splitPaneIds = JSON.parse(rawSplitPaneIds) } catch {}
98+
}
99+
93100
return {
94101
chatId: chatId || undefined,
95102
subChatId: subChatId || undefined,
103+
splitPaneIds,
96104
}
97105
}

src/renderer/features/agents/atoms/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,31 @@ export const diffFilesCollapsedAtomFamily = atomFamily((chatId: string) =>
378378
),
379379
)
380380

381+
// Helpers for split view ratio management
382+
export function getDefaultRatios(n: number): number[] {
383+
if (n <= 0) return []
384+
return Array(n).fill(1 / n) as number[]
385+
}
386+
387+
export function addPaneRatio(ratios: number[]): number[] {
388+
const n = ratios.length + 1
389+
const scale = (n - 1) / n
390+
return [...ratios.map(r => r * scale), 1 / n]
391+
}
392+
393+
export function removePaneRatio(ratios: number[], removeIdx: number): number[] {
394+
if (removeIdx < 0 || removeIdx >= ratios.length) return getDefaultRatios(ratios.length)
395+
const removed = ratios[removeIdx]!
396+
const rest = ratios.filter((_, i) => i !== removeIdx)
397+
if (rest.length === 0) return []
398+
const sum = rest.reduce((a, b) => a + b, 0)
399+
if (sum === 0) return getDefaultRatios(rest.length)
400+
const result = rest.map(r => r + (r / sum) * removed)
401+
// Normalize to prevent floating-point drift
402+
const total = result.reduce((a, b) => a + b, 0)
403+
return total > 0 ? result.map(r => r / total) : getDefaultRatios(rest.length)
404+
}
405+
381406
// Sub-chats display mode - tabs (horizontal) or sidebar (vertical list)
382407
// Window-scoped so each window can have its own layout preference
383408
export const agentsSubChatsSidebarModeAtom = atomWithWindowStorage<

src/renderer/features/agents/components/agents-help-popover.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,31 @@ interface ReleaseHighlight {
2020
title: string
2121
}
2222

23-
function parseFirstHighlight(content: string): string {
24-
const lines = content.split("\n")
25-
let inFeatures = false
23+
function parseFirstItemFromSection(lines: string[], sectionPattern: RegExp): string | null {
24+
let inSection = false
2625
for (const line of lines) {
27-
if (/^###\s+Features/i.test(line)) {
28-
inFeatures = true
26+
if (sectionPattern.test(line)) {
27+
inSection = true
2928
continue
3029
}
31-
if (inFeatures && /^###?\s+/.test(line)) break
32-
if (inFeatures) {
30+
if (inSection && /^###?\s+/.test(line)) break
31+
if (inSection) {
3332
const bold = line.match(/^[-*]\s+\*\*(.+?)\*\*/)
3433
if (bold) return bold[1]
3534
const plain = line.match(/^[-*]\s+(.+)/)
3635
if (plain) return plain[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim()
3736
}
3837
}
39-
return "Bug fixes & improvements"
38+
return null
39+
}
40+
41+
function parseFirstHighlight(content: string): string {
42+
const lines = content.split("\n")
43+
return (
44+
parseFirstItemFromSection(lines, /^###\s+Features/i) ??
45+
parseFirstItemFromSection(lines, /^###\s+Improvements/i) ??
46+
"Bug fixes & improvements"
47+
)
4048
}
4149

4250
interface AgentsHelpPopoverProps {

0 commit comments

Comments
 (0)