Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/server/src/git/Layers/ClaudeTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
' "Reconnect failures after restart because the session state does not recover" ',
},
}),
stdinMustContain: "You write concise thread titles for coding conversations.",
stdinMustContain: "Do not exceed 4 words.",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
});

expect(generated.title).toBe("Investigate websocket reconnect regressions aft...");
expect(generated.title).toBe("Investigate websocket reconnect");
}),
),
);
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/git/Prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ describe("buildThreadTitlePrompt", () => {

expect(result.prompt).toContain("User message:");
expect(result.prompt).toContain("Investigate reconnect regressions after session restore");
expect(result.prompt).toContain("Keep it short and specific (2-4 words).");
expect(result.prompt).toContain("Do not exceed 4 words.");
expect(result.prompt).not.toContain("Attachment metadata:");
});

Expand All @@ -137,12 +139,12 @@ describe("buildThreadTitlePrompt", () => {
});

describe("sanitizeThreadTitle", () => {
it("truncates long titles with the shared sidebar-safe limit", () => {
it("compresses long titles down to a four-word label", () => {
expect(
sanitizeThreadTitle(
' "Reconnect failures after restart because the session state does not recover" ',
),
).toBe("Reconnect failures after restart because the se...");
).toBe("Reconnect failures after restart");
});
});

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/git/Prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
responseShape: "Return a JSON object with key: title.",
rules: [
"Title should summarize the user's request, not restate it verbatim.",
"Keep it short and specific (3-8 words).",
"Keep it short and specific (2-4 words).",
"Do not exceed 4 words.",
"Prefer a compact task label, not a sentence or status update.",
"Avoid quotes, filler, prefixes, and trailing punctuation.",
"If images are attached, use them as primary context for visual/UI issues.",
],
Expand Down
19 changes: 2 additions & 17 deletions apps/server/src/git/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { Schema } from "effect";

import { TextGenerationError } from "@t3tools/contracts";
import { sanitizeGeneratedThreadTitle } from "@t3tools/shared/chatThreads";

import { existsSync } from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -55,23 +56,7 @@ export function sanitizePrTitle(raw: string): string {

/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
export function sanitizeThreadTitle(raw: string): string {
const normalized = raw
.trim()
.split(/\r?\n/g)[0]
?.trim()
.replace(/^['"`]+|['"`]+$/g, "")
.trim()
.replace(/\s+/g, " ");

if (!normalized || normalized.trim().length === 0) {
return "New thread";
}

if (normalized.length <= 50) {
return normalized;
}

return `${normalized.slice(0, 47).trimEnd()}...`;
return sanitizeGeneratedThreadTitle(raw);
}

/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@t3tools/contracts";
import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import { GENERIC_CHAT_THREAD_TITLE } from "@t3tools/shared/chatThreads";

import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
import { GitCore } from "../../git/Services/GitCore.ts";
Expand Down Expand Up @@ -74,11 +75,10 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
const WORKTREE_BRANCH_PREFIX = "t3code";
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
const DEFAULT_THREAD_TITLE = "New thread";

function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
const trimmedCurrentTitle = currentTitle.trim();
if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) {
if (trimmedCurrentTitle === GENERIC_CHAT_THREAD_TITLE) {
return true;
}

Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime";
import { buildPromptThreadTitleFallback } from "@t3tools/shared/chatThreads";
import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { truncate } from "@t3tools/shared/String";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
Expand Down Expand Up @@ -2626,7 +2626,7 @@ export default function ChatView(props: ChatViewProps) {
? formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!)
: null,
});
const title = truncate(titleSeed);
const title = buildPromptThreadTitleFallback(titleSeed);
const threadCreateModelSelection: ModelSelection = {
provider: ctxSelectedProvider,
model:
Expand Down Expand Up @@ -3090,7 +3090,9 @@ export default function ChatView(props: ChatViewProps) {
effort: ctxSelectedPromptEffort,
text: implementationPrompt,
});
const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
const nextThreadTitle = buildPromptThreadTitleFallback(
buildPlanImplementationThreadTitle(planMarkdown),
);
const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;

sendInFlightRef.current = true;
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,9 @@ describe("composerDraftStore file references", () => {
.getOptions()
.merge(persistedState, useComposerDraftStore.getInitialState());

expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.fileReferences).toEqual([
expect(
mergedState.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.fileReferences,
).toEqual([
{
id: "ref-1",
name: "report.pdf",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ function makeState(thread: Thread): AppState {
updatedAt: thread.updatedAt,
branch: thread.branch,
worktreePath: thread.worktreePath,
loop: thread.loop ?? null,
},
},
threadSessionById: {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ function toThreadShell(thread: Thread): ThreadShell {
updatedAt: thread.updatedAt,
branch: thread.branch,
worktreePath: thread.worktreePath,
loop: thread.loop ?? null,
};
}

Expand Down Expand Up @@ -336,7 +337,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b
left.archivedAt === right.archivedAt &&
left.updatedAt === right.updatedAt &&
left.branch === right.branch &&
left.worktreePath === right.worktreePath
left.worktreePath === right.worktreePath &&
left.loop === right.loop
);
}

Expand Down Expand Up @@ -473,6 +475,7 @@ function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefi
activities: selectThreadActivities(state, threadId),
proposedPlans: selectThreadProposedPlans(state, threadId),
turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId),
loop: shell.loop ?? null,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { GENERIC_CHAT_THREAD_TITLE } from "@t3tools/shared/chatThreads";
import { useCallback, type MutableRefObject } from "react";

import { collapseExpandedComposerCursor } from "~/composer-logic";
Expand Down Expand Up @@ -77,7 +78,7 @@ export function useComposerFileReferenceSend(input: {
if (input.terminalContexts.length > 0) {
return input.terminalContextLabel ?? "Terminal context";
}
return "New thread";
return GENERIC_CHAT_THREAD_TITLE;
},
[],
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export interface ThreadShell {
updatedAt?: string | undefined;
branch: string | null;
worktreePath: string | null;
loop?: ThreadLoop | null;
}

export interface ThreadTurnState {
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
"types": "./src/projectScripts.ts",
"import": "./src/projectScripts.ts"
},
"./chatThreads": {
"types": "./src/chatThreads.ts",
"import": "./src/chatThreads.ts"
},
"./qrCode": {
"types": "./src/qrCode.ts",
"import": "./src/qrCode.ts"
Expand Down
39 changes: 39 additions & 0 deletions packages/shared/src/chatThreads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";

import {
buildPromptThreadTitleFallback,
GENERIC_CHAT_THREAD_TITLE,
isGenericChatThreadTitle,
sanitizeGeneratedThreadTitle,
} from "./chatThreads";

describe("chatThreads", () => {
it("builds short fallback titles from prompt text", () => {
expect(
buildPromptThreadTitleFallback("Investigate reconnect regressions after session restore"),
).toBe("Investigate reconnect regressions after");
});

it("falls back to the generic title when the source is empty", () => {
expect(buildPromptThreadTitleFallback(" \n\t ")).toBe(GENERIC_CHAT_THREAD_TITLE);
});

it("sanitizes generated titles down to four words", () => {
expect(
sanitizeGeneratedThreadTitle('"Reconnect failures after restart because state is stale."'),
).toBe("Reconnect failures after restart");
});

it("drops extra words before truncating mid-word", () => {
expect(
sanitizeGeneratedThreadTitle(
'"Investigate websocket reconnect regressions after worktree restore"',
),
).toBe("Investigate websocket reconnect");
});

it("keeps generic title checks whitespace-safe", () => {
expect(isGenericChatThreadTitle(" New thread ")).toBe(true);
expect(isGenericChatThreadTitle("Manual rename")).toBe(false);
});
});
60 changes: 60 additions & 0 deletions packages/shared/src/chatThreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export const GENERIC_CHAT_THREAD_TITLE = "New thread";

const MAX_CHAT_THREAD_TITLE_WORDS = 4;
const MAX_CHAT_THREAD_TITLE_CHARS = 40;

function normalizeTitleWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}

function trimTitleToken(token: string): string {
return token.replace(/^[\s"'`([{]+|[\s"'`)\]}:;,.!?]+$/g, "");
}

function titleWords(value: string): string[] {
return normalizeTitleWhitespace(value)
.split(" ")
.map(trimTitleToken)
.filter((token) => token.length > 0);
}

function truncateChatThreadTitle(text: string): string {
if (text.length <= MAX_CHAT_THREAD_TITLE_CHARS) {
return text;
}

return `${text.slice(0, MAX_CHAT_THREAD_TITLE_CHARS - 3).trimEnd()}...`;
}

function compactChatThreadTitle(value: string): string {
const unquoted = normalizeTitleWhitespace(value).replace(/^['"`]+|['"`]+$/g, "");
const words = titleWords(unquoted).slice(0, MAX_CHAT_THREAD_TITLE_WORDS);
if (words.length === 0) {
return GENERIC_CHAT_THREAD_TITLE;
}

const compactWords = [...words];
while (compactWords.length > 2 && compactWords.join(" ").length > MAX_CHAT_THREAD_TITLE_CHARS) {
compactWords.pop();
}

const compactTitle = compactWords.join(" ");
if (compactTitle.length <= MAX_CHAT_THREAD_TITLE_CHARS) {
return compactTitle;
}

return truncateChatThreadTitle(compactTitle);
}

export function buildPromptThreadTitleFallback(message: string): string {
return compactChatThreadTitle(message);
}

export function sanitizeGeneratedThreadTitle(raw: string): string {
const firstLine = raw.trim().split(/\r?\n/g)[0] ?? "";
return compactChatThreadTitle(firstLine);
}

export function isGenericChatThreadTitle(title: string | null | undefined): boolean {
return normalizeTitleWhitespace(title ?? "") === GENERIC_CHAT_THREAD_TITLE;
}
28 changes: 28 additions & 0 deletions scripts/setup-worktree.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"

echo "==> Worktree: $ROOT"

if ! command -v bun >/dev/null 2>&1; then
echo "Erro: bun nao esta instalado no PATH."
exit 1
fi

mkdir -p "${T3CODE_HOME:-$HOME/.t3}"

echo "==> Instalando dependencias..."
bun install

if ! command -v codex >/dev/null 2>&1 && ! command -v claude >/dev/null 2>&1; then
echo
echo "Aviso: nem 'codex' nem 'claude' estao no PATH."
echo "O app sobe, mas voce nao vai conseguir usar provider real."
fi

echo
echo "Setup concluido."
echo "Proximo passo:"
echo " bun dev"
Loading