Merge remote-tracking branch 'origin/main' into fix/pr-1301-maintainer

This commit is contained in:
程序员阿江(Relakkes)
2026-09-07 22:47:14 +08:00
38 changed files with 659 additions and 88 deletions
+10
View File
@@ -126,8 +126,16 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install root dependencies
run: bun install --frozen-lockfile
- name: Install desktop dependencies
working-directory: desktop
run: bun install --frozen-lockfile
- name: Install adapter dependencies
working-directory: adapters
run: bun install --frozen-lockfile
- name: Run root runtime checks
run: bun run check:server
@@ -287,6 +295,8 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install root dependencies
run: bun install --frozen-lockfile
- name: Install desktop dependencies
+1 -4
View File
@@ -1,10 +1,7 @@
# Claude Code Haha
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/images/logo-horizontal-dark.png">
<img src="docs/images/logo-horizontal.png" alt="Claude Code Haha" width="480">
</picture>
<img src="docs/images/readme-cover-en.jpg" alt="cc-haha — Open-source desktop for Claude Code" width="960">
</p>
<div align="center">
+1 -4
View File
@@ -1,10 +1,7 @@
# Claude Code Haha
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/images/logo-horizontal-dark.png">
<img src="docs/images/logo-horizontal.png" alt="Claude Code Haha" width="480">
</picture>
<img src="docs/images/readme-cover-zh.jpg" alt="cc-haha — Claude Code 开源桌面端" width="960">
</p>
<div align="center">
@@ -93,7 +93,7 @@ describe('ModelSelector', () => {
await clickByRole(/Opus 4\.7/i)
expect(screen.getByRole('button', { name: /Fable 5/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Fable 5\.1/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Opus 4\.8/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sonnet 5/ })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /Opus 4\.7/ }).length).toBeGreaterThan(0)
+8
View File
@@ -3,6 +3,14 @@ import type { ModelInfo } from '../types/settings'
export const OFFICIAL_DEFAULT_MODEL_ID = 'claude-opus-4-8'
export const OFFICIAL_MODELS: ModelInfo[] = [
{
id: 'claude-fable-5-1',
name: 'Fable 5.1',
description: 'Highest capability for long-running tasks',
context: '1m',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-fable-5',
name: 'Fable 5',
+28 -9
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceSession } from './TraceSession'
@@ -503,16 +503,35 @@ describe('TraceSession', () => {
},
calls: [baseTrace.calls[0]!, makeCall({ id: 'call-2', startedAt: '2026-06-09T10:00:08.000Z' })],
})
await renderReady(20)
// Advance polls only after each state is asserted. A real 20ms interval can
// invalidate the detail cache before the initial assertion under coverage.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
try {
await renderReady(20)
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3))
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
await screen.findByText('claude-sonnet-4-5 x2')
expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1)
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
for (const expectedTraceCalls of [2, 3]) {
await act(async () => { await vi.advanceTimersByTimeAsync(20) })
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(expectedTraceCalls)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
expect(screen.queryByText('claude-sonnet-4-5 x2')).not.toBeInTheDocument()
}
await act(async () => { await vi.advanceTimersByTimeAsync(20) })
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(4)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)
expect(screen.getByText('claude-sonnet-4-5 x2')).toBeInTheDocument()
expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1)
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
} finally {
cleanup()
vi.useRealTimers()
}
})
it('does not refetch full messages for identical trace polls', async () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+23 -1
View File
@@ -4,7 +4,7 @@ import { parse } from 'yaml'
type WorkflowJob = {
needs?: string | string[]
steps?: Array<{ name?: string; run?: string }>
steps?: Array<{ name?: string; run?: string; 'working-directory'?: string }>
}
function workflowJobs(workflow: string) {
@@ -70,6 +70,28 @@ describe('PR quality workflow', () => {
}
})
test('installs imported workspace dependencies and ripgrep before runtime and coverage tests', () => {
const jobs = workflowJobs(readFileSync('.github/workflows/pr-quality.yml', 'utf8'))
for (const [job, command] of [
['server-checks', 'bun run check:server'],
['coverage-checks', 'bun run check:coverage'],
]) {
const steps = jobs[job].steps ?? []
const check = steps.findIndex(step => step.run === command)
expect(check).toBeGreaterThanOrEqual(0)
for (const workspace of ['desktop', 'adapters']) {
const install = steps.findIndex(step =>
step['working-directory'] === workspace && step.run === 'bun install --frozen-lockfile',
)
expect(install).toBeGreaterThanOrEqual(0)
expect(install).toBeLessThan(check)
}
const ripgrep = steps.findIndex(step => step.run?.includes('apt-get install -y ripgrep'))
expect(ripgrep).toBeGreaterThanOrEqual(0)
expect(ripgrep).toBeLessThan(check)
}
})
test('keeps coverage artifacts observable in CI', () => {
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
+15 -6
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { feature } from 'bun:bundle'
import {
resetSettingsCache,
@@ -8,14 +8,23 @@ import {
process.env.ANTHROPIC_API_KEY = 'test-key'
let capturedQuery: Record<string, unknown> | undefined
mock.module('../../utils/sideQuery.js', () => ({
sideQuery: async (options: Record<string, unknown>) => {
const sideQueryModule = await import('../../utils/sideQuery.js')
beforeEach(() => {
spyOn(sideQueryModule, 'sideQuery').mockImplementation(async options => {
capturedQuery = options
return {
id: 'msg_critique',
type: 'message',
role: 'assistant',
model: 'test-model',
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
content: [{ type: 'text', text: 'critique complete' }],
}
},
}))
} as Awaited<ReturnType<typeof sideQueryModule.sideQuery>>
})
})
const transcriptClassifierEnabled = feature('TRANSCRIPT_CLASSIFIER')
? true
+1
View File
@@ -3,6 +3,7 @@ import { feature } from 'bun:bundle'
export const CLAUDE_CODE_20250219_BETA_HEADER = 'claude-code-20250219'
export const INTERLEAVED_THINKING_BETA_HEADER =
'interleaved-thinking-2025-05-14'
export const THINKING_BINDING_CONTROLS_BETA_HEADER = 'thinking-binding-controls-2026-08-01'
export const CONTEXT_1M_BETA_HEADER = 'context-1m-2025-08-07'
export const CONTEXT_MANAGEMENT_BETA_HEADER = 'context-management-2025-06-27'
export const STRUCTURED_OUTPUTS_BETA_HEADER = 'structured-outputs-2025-12-15'
+15 -1
View File
@@ -374,8 +374,9 @@ describe('Business Flow: Models & Effort', () => {
it('should return available fallback models', async () => {
const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(4)
expect(data.models.length).toBe(5)
const names = data.models.map((m: any) => m.name)
expect(names).toContain('Fable 5.1')
expect(names).toContain('Fable 5')
expect(names).toContain('Opus 4.8')
expect(names).toContain('Sonnet 5')
@@ -398,6 +399,19 @@ describe('Business Flow: Models & Effort', () => {
expect(data.model.name).toBe('Opus 4.8')
})
it('should select Fable 5.1 with its reasoning catalog intact', async () => {
const { status } = await api('PUT', '/api/models/current', {
modelId: 'claude-fable-5-1',
})
expect(status).toBe(200)
const { data } = await api('GET', '/api/models/current')
expect(data.model).toMatchObject({
id: 'claude-fable-5-1', name: 'Fable 5.1', context: '1m',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
})
})
it('should switch to Haiku 4.5', async () => {
await api('PUT', '/api/models/current', { modelId: 'claude-haiku-4-5' })
const { data } = await api('GET', '/api/models/current')
+2 -2
View File
@@ -216,8 +216,8 @@ describe('E2E: Full Flow', () => {
it('should list available models', async () => {
const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(4)
expect(data.models[0].name).toBe('Fable 5')
expect(data.models.length).toBe(5)
expect(data.models[0].name).toBe('Fable 5.1')
})
it('should switch model', async () => {
+9
View File
@@ -713,6 +713,14 @@ describe('Models API', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.models).toEqual([
{
id: 'claude-fable-5-1',
name: 'Fable 5.1',
description: 'Highest capability for long-running tasks',
context: '1m',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-fable-5',
name: 'Fable 5',
@@ -971,6 +979,7 @@ describe('Models API', () => {
)
const listBody = await listResponse.json()
expect(listBody.models.map((model: { id: string }) => model.id)).toEqual([
'claude-fable-5-1',
'claude-fable-5',
'claude-opus-4-8',
'claude-sonnet-5',
+7 -5
View File
@@ -2250,12 +2250,14 @@ describe('TeamService', () => {
blocks: [],
blockedBy: [],
})
expect(await readTaskListSnapshot(teamName)).toMatchObject({
const activeSnapshot = await readTaskListSnapshot(teamName)
expect(activeSnapshot.tasks).toHaveLength(2)
expect(activeSnapshot).toMatchObject({
revision: 2,
tasks: [
{ id: '1', subject: 'Only generation-two task' },
{ id: '2', subject: 'Active generation-two writer' },
],
tasks: expect.arrayContaining([
expect.objectContaining({ id: '1', subject: 'Only generation-two task' }),
expect.objectContaining({ id: '2', subject: 'Active generation-two writer' }),
]),
})
} finally {
writerResource.emitDestroy()
+8
View File
@@ -50,6 +50,14 @@ import {
// ─── Fallback models (used when no provider is configured) ────────────────────
const DEFAULT_MODELS = [
{
id: 'claude-fable-5-1',
name: 'Fable 5.1',
description: 'Highest capability for long-running tasks',
context: '1m',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
},
{
id: 'claude-fable-5',
name: 'Fable 5',
+17 -1
View File
@@ -134,6 +134,7 @@ import {
} from "src/bootstrap/state.js";
import {
AFK_MODE_BETA_HEADER,
THINKING_BINDING_CONTROLS_BETA_HEADER,
CONTEXT_1M_BETA_HEADER,
CONTEXT_MANAGEMENT_BETA_HEADER,
EFFORT_BETA_HEADER,
@@ -191,6 +192,7 @@ import {
import { endQueryProfile, queryCheckpoint } from "src/utils/queryProfiler.js";
import {
modelSupportsAdaptiveThinking,
modelUsesBoundThinking,
modelSupportsThinking,
resolveModelThinkingEnabled,
shouldSendExplicitDisabledThinking,
@@ -1751,7 +1753,8 @@ async function* queryModel(
// setting that can greatly affect model quality and bashing.
if (hasThinking && modelCanThink) {
if (
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING) &&
(modelUsesBoundThinking(options.model) ||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING)) &&
modelSupportsAdaptiveThinking(options.model)
) {
// For models that support adaptive thinking, always use adaptive
@@ -1781,6 +1784,19 @@ async function* queryModel(
} as unknown as BetaMessageStreamParams['thinking']
}
if (thinking?.type === 'adaptive' && modelUsesBoundThinking(options.model)) {
// Directory, tool and compacted-history updates can invalidate old thinking.
// Let the API retain valid blocks and drop only those bound to an old prefix.
// This header is required for compatibility even when optional betas are disabled.
thinking = {
...thinking,
block_binding: { prefix_mismatch_behavior: 'drop_block' },
} as typeof thinking
if (!betasParams.includes(THINKING_BINDING_CONTROLS_BETA_HEADER)) {
betasParams.push(THINKING_BINDING_CONTROLS_BETA_HEADER)
}
}
// Get API context management strategies if enabled
const contextManagement = getAPIContextManagement({
hasThinking,
+110 -20
View File
@@ -10,13 +10,18 @@ import { enableConfigs } from '../../utils/config.js'
import { get3PModelCapabilityOverride } from '../../utils/model/modelSupportOverrides.js'
import { buildProviderManagedEnv } from '../../server/services/providerRuntimeEnv.js'
import type { SavedProvider } from '../../server/types/provider.js'
import { queryWithModel } from './claude.js'
import { queryModelWithStreaming, queryWithModel } from './claude.js'
import { createUserMessage } from '../../utils/messages.js'
import { asSystemPrompt } from '../../utils/systemPromptType.js'
import { getEmptyToolPermissionContext } from '../../Tool.js'
import type { Message } from '../../types/message.js'
function sseEvent(name: string, data: unknown): string {
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
}
function successfulResponse(model: string): string {
function successfulResponse(model: string, withThinking = false): string {
const textIndex = withThinking ? 1 : 0
return [
sseEvent('message_start', {
type: 'message_start',
@@ -31,17 +36,28 @@ function successfulResponse(model: string): string {
usage: { input_tokens: 1, output_tokens: 0 },
},
}),
...(withThinking ? [
sseEvent('content_block_start', {
type: 'content_block_start', index: 0,
content_block: { type: 'thinking', thinking: '', signature: '' },
}),
sseEvent('content_block_delta', {
type: 'content_block_delta', index: 0,
delta: { type: 'signature_delta', signature: 'fixture-signature' },
}),
sseEvent('content_block_stop', { type: 'content_block_stop', index: 0 }),
] : []),
sseEvent('content_block_start', {
type: 'content_block_start',
index: 0,
index: textIndex,
content_block: { type: 'text', text: '' },
}),
sseEvent('content_block_delta', {
type: 'content_block_delta',
index: 0,
index: textIndex,
delta: { type: 'text_delta', text: 'OK' },
}),
sseEvent('content_block_stop', { type: 'content_block_stop', index: 0 }),
sseEvent('content_block_stop', { type: 'content_block_stop', index: textIndex }),
sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
@@ -298,6 +314,7 @@ async function captureQueryRequest({
globalThinkingEnabled,
provider,
responseFactory,
continuationSystemPrompts,
env,
}: {
model: string
@@ -307,7 +324,8 @@ async function captureQueryRequest({
configureCapabilityOverrides?: boolean
globalThinkingEnabled?: boolean
provider?: SavedProvider
responseFactory?: (model: string, body: Record<string, unknown>) => Response
responseFactory?: (model: string, body: Record<string, unknown>, headers: Headers) => Response
continuationSystemPrompts?: string[][]
env?: Readonly<Record<string, string | undefined>>
}): Promise<{
content: unknown
@@ -325,7 +343,7 @@ async function captureQueryRequest({
requestHeaders.push(new Headers(request.headers))
const body = await request.json() as Record<string, unknown>
requests.push(body)
return responseFactory?.(model, body) ?? new Response(successfulResponse(model), {
return responseFactory?.(model, body, request.headers) ?? new Response(successfulResponse(model), {
headers: { 'content-type': 'text/event-stream' },
})
},
@@ -386,19 +404,46 @@ async function captureQueryRequest({
clearCapabilityCache()
enableConfigs()
const result = await queryWithModel({
userPrompt: 'Reply exactly OK',
signal: new AbortController().signal,
options: {
model,
querySource: 'insights',
agents: [],
isNonInteractiveSession: true,
hasAppendSystemPrompt: false,
mcpTools: [],
effortValue,
},
})
const options = {
model,
querySource: 'insights' as const,
agents: [],
isNonInteractiveSession: true,
hasAppendSystemPrompt: false,
mcpTools: [],
effortValue,
}
let result
if (continuationSystemPrompts) {
const history: Message[] = []
for (const [index, systemPrompt] of [[], ...continuationSystemPrompts].entries()) {
history.push(createUserMessage({ content: index === 0 ? 'Reply exactly OK' : 'Continue' }))
const assistants = []
for await (const message of queryModelWithStreaming({
messages: history,
systemPrompt: asSystemPrompt(systemPrompt),
thinkingConfig: { type: 'disabled' },
tools: [],
signal: new AbortController().signal,
options: {
...options,
enablePromptCaching: false,
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
},
})) {
if (message.type === 'assistant') assistants.push(message)
}
history.push(...assistants)
result = assistants.at(-1)
}
} else {
result = await queryWithModel({
userPrompt: 'Reply exactly OK',
signal: new AbortController().signal,
options,
})
}
if (!result) throw new Error('No assistant response received')
return {
content: result.message.content,
@@ -541,6 +586,51 @@ test('normalizes a disabled parent thinking mode to adaptive for Fable', async (
expect(requests[0]?.thinking).not.toEqual({ type: 'disabled' })
}, 10_000)
for (const [disableBetas, disableAdaptive] of [[false, false], [true, false], [false, true]]) {
test(`keeps Fable 5.1 replay usable after a system change (disable betas: ${disableBetas}, adaptive: ${disableAdaptive})`, async () => {
let originalSystem: string | undefined
let responseIndex = 0
const { content, requests, requestHeaders } = await captureQueryRequest({
model: 'claude-fable-5-1',
configureCapabilityOverrides: false,
continuationSystemPrompts: [[], ['Additional working directories: /tmp/project-two']],
env: {
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: disableBetas ? '1' : undefined,
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: disableAdaptive ? '1' : undefined,
},
responseFactory: (model, body, headers) => {
const system = JSON.stringify(body.system)
const changed = originalSystem !== undefined && system !== originalSystem
originalSystem ??= system
const thinking = body.thinking as { block_binding?: { prefix_mismatch_behavior?: string } }
if (changed && (
thinking?.block_binding?.prefix_mismatch_behavior !== 'drop_block' ||
!headers.get('anthropic-beta')?.includes('thinking-binding-controls-2026-08-01')
)) {
return Response.json({ type: 'error', error: {
type: 'invalid_request_error', message: 'The block is bound to a different conversation',
} }, { status: 400 })
}
const response = successfulResponse(model, true).replaceAll('msg_required_thinking', `msg_replay_${responseIndex++}`)
return new Response(response, { headers: { 'content-type': 'text/event-stream' } })
},
})
expect(content).toContainEqual({ type: 'text', text: 'OK' })
expect(requests).toHaveLength(3)
for (let index = 0; index < requests.length; index++) {
expect(requests[index]?.model).toBe('claude-fable-5-1')
expect(requests[index]?.thinking).toEqual({
type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'drop_block' },
})
expect(requestHeaders[index]?.get('anthropic-beta')).toContain('thinking-binding-controls-2026-08-01')
}
for (const request of requests.slice(1)) {
expect(JSON.stringify(request.messages)).toContain('fixture-signature')
}
}, 10_000)
}
test('drops a tool call truncated at the output-token boundary', async () => {
const { content, apiError, error, requests } = await captureQueryRequest({
model: 'deepseek-v4-flash',
+9 -6
View File
@@ -478,27 +478,30 @@ describe('Task tool execution ordering', () => {
expect(listed.data.taskListSnapshotRevision).toBe(2)
expect(updated.data.taskListMutationRevision).toBe(3)
expect(afterUpdate.data.taskListSnapshotRevision).toBe(3)
expect(listed.data.tasks).toEqual([
expect(listed.data.tasks).toHaveLength(2)
expect(listed.data.tasks).toEqual(expect.arrayContaining([
expect.objectContaining({ id: taskId, status: 'pending' }),
expect.objectContaining({ id: dependent.data.task.id, status: 'pending' }),
])
expect(afterUpdate.data.tasks).toEqual([
]))
expect(afterUpdate.data.tasks).toHaveLength(2)
expect(afterUpdate.data.tasks).toEqual(expect.arrayContaining([
expect.objectContaining({ id: taskId, status: 'in_progress' }),
expect.objectContaining({
id: dependent.data.task.id,
blockedBy: [taskId],
}),
])
]))
appState = {
...appState,
teamContext: { teamName: taskListId },
}
const deleted = await TeamDeleteTool.call({}, context)
expect(deleted.data.finalTasks).toHaveLength(2)
expect(deleted.data).toMatchObject({
success: true,
team_name: taskListId,
finalTasks: [
finalTasks: expect.arrayContaining([
expect.objectContaining({
id: taskId,
status: 'in_progress',
@@ -508,7 +511,7 @@ describe('Task tool execution ordering', () => {
id: dependent.data.task.id,
blockedBy: [taskId],
}),
],
]),
})
expect(Number.isFinite(Date.parse(deleted.data.taskListSnapshotAt ?? ''))).toBe(true)
expect(deleted.data.taskListSnapshotRevision).toBe(3)
+1
View File
@@ -153,6 +153,7 @@ export function sanitizeSurfaceKey(surfaceKey: string): string {
*/
export function sanitizeModelName(shortName: string): string {
// Map internal variants to public equivalents based on model family
if (shortName.includes('fable-5-1')) return 'claude-fable-5-1'
if (shortName.includes('fable-5')) return 'claude-fable-5'
if (shortName.includes('opus-4-8')) return 'claude-opus-4-8'
if (shortName.includes('opus-4-6')) return 'claude-opus-4-7'

Some files were not shown because too many files have changed in this diff Show More