Skip to content
Merged
15 changes: 15 additions & 0 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,13 +785,28 @@ func (a *App) NewSession() error {
if ctrl == nil {
return nil
}
// Tab is already blank — just persist and skip the new-session dance.
if !ctrl.Running() && !messagesHaveConversationContent(ctrl.History()) {
a.persistTabSessionPath(tab, ctrl.SessionPath())
return nil
}

if err := ctrl.NewSession(); err != nil {
return err
}
a.persistTabSessionPath(tab, ctrl.SessionPath())
return nil
}

func messagesHaveConversationContent(messages []provider.Message) bool {
for _, msg := range messages {
if msg.Role != provider.RoleSystem {
return true
}
}
return false
}

// ClearSession discards the current conversation and rotates to a fresh unsaved one.
func (a *App) ClearSession() error {
a.mu.RLock()
Expand Down
120 changes: 120 additions & 0 deletions desktop/app_session_dedup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,123 @@ func TestCarriedRebuildsKeepOneSession(t *testing.T) {
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
}
}

// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.

func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
isolateDesktopUserDirs(t)

app := NewApp()
first, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
second, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}

// EnsureBlankTab reuses an already-open project-scoped blank tab.

func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
isolateDesktopUserDirs(t)

projectRoot := t.TempDir()
app := NewApp()
first, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
second, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}

// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for global scope.

func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)

app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatal(err)
}

meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
}
}

// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for project scope.

func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)

projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatal(err)
}

meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
var topics []string
for _, project := range loadProjectsFile().Projects {
if project.Root == projectRoot {
topics = project.Topics
break
}
}
if len(topics) != 1 {
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
}
}

// NewSession skips the snapshot when the current tab has no real conversation content.

func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
isolateDesktopUserDirs(t)

dir := t.TempDir()
path := agent.NewSessionPath(dir, "model-a")
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
app := NewApp()
app.setTestCtrl(ctrl, "model-a")

if err := app.NewSession(); err != nil {
t.Fatal(err)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
}
}
31 changes: 18 additions & 13 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ export default function App() {
closeTab,
reorderTabs,
syncActiveTab,
ensureBlankTab,
} = useController();
const { locale, setPref: setLocalePref } = useI18n();
const t = useT();
Expand Down Expand Up @@ -1029,6 +1030,19 @@ export default function App() {
return tabs;
}, []);

const blankSessionTarget = useCallback(() => {
const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : "";
const scope = activeWorkspaceRoot ? "project" : "global";
return { scope, workspaceRoot: activeWorkspaceRoot };
}, [activeTab?.scope, activeTab?.workspaceRoot]);

const openBlankSession = useCallback(async (scope: string, workspaceRoot: string) => {
await ensureBlankTab(scope, scope === "project" ? workspaceRoot : "");
setProjectRevision((value) => value + 1);
await refreshTabMetas();
setTabRevealSignal((signal) => signal + 1);
}, [ensureBlankTab, refreshTabMetas]);

useEffect(() => {
void refreshTabMetas();
const id = window.setInterval(() => void refreshTabMetas(), 2000);
Expand Down Expand Up @@ -1401,19 +1415,9 @@ export default function App() {

const handleNewTab = useCallback(async () => {
closeTransientOverlays();
const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : "";
const targetScope = activeWorkspaceRoot ? "project" : "global";
const workspaceRoot = activeWorkspaceRoot;
const topic = await app.CreateTopic(targetScope, workspaceRoot, "");
if (targetScope === "global" || !workspaceRoot) {
await openGlobalTab(topic.id);
} else {
await openProjectTab(workspaceRoot, topic.id);
}
setProjectRevision((value) => value + 1);
await refreshTabMetas();
setTabRevealSignal((signal) => signal + 1);
}, [activeTab?.scope, activeTab?.workspaceRoot, closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]);
const target = blankSessionTarget();
await openBlankSession(target.scope, target.workspaceRoot);
}, [blankSessionTarget, closeTransientOverlays, openBlankSession]);

const handleMessageAction = useCallback(async (turn: number, scope: string) => {
await rewind(turn, scope);
Expand Down Expand Up @@ -1736,6 +1740,7 @@ export default function App() {
activeTopicId={activeTab?.topicId}
onOpenTopic={handleOpenTopic}
onOpenProjectHistory={openProjectHistory}
onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
onTopicsChanged={refreshProjectsAndTabs}
onRenameTopic={renameTopic}
refreshSignal={projectRevision}
Expand Down
8 changes: 8 additions & 0 deletions desktop/frontend/src/components/ProjectTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface ProjectTreeProps {
onOpenTopic: (scope: string, workspaceRoot: string, topicId: string) => Promise<void> | void;
onOpenProjectHistory: (scope: "global" | "project", workspaceRoot: string) => Promise<void> | void;
onAddProject: () => Promise<void>;
onCreateTopic?: (scope: string, workspaceRoot: string) => Promise<void> | void;
onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
onTopicsChanged?: () => Promise<void> | void;
refreshSignal?: number;
Expand Down Expand Up @@ -193,6 +194,7 @@ export function ProjectTree({
onOpenTopic,
onOpenProjectHistory,
onAddProject,
onCreateTopic,
onRenameTopic,
onTopicsChanged,
refreshSignal,
Expand Down Expand Up @@ -371,6 +373,12 @@ export function ProjectTree({
return next;
});
try {
if (onCreateTopic) {
await onCreateTopic(scope, workspaceRoot);
await refresh();
await onTopicsChanged?.();
return;
}
const topic = await app.CreateTopic(scope, workspaceRoot, "");
await refresh();
await onTopicsChanged?.();
Expand Down
16 changes: 16 additions & 0 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export interface AppBindings {
ListTabs(): Promise<TabMeta[]>;
OpenProjectTab(workspaceRoot: string, topicID: string): Promise<TabMeta>;
OpenGlobalTab(topicID: string): Promise<TabMeta>;
EnsureBlankTab(scope: string, workspaceRoot: string): Promise<TabMeta>;
SetActiveTab(tabID: string): Promise<void>;
ReorderTabs(tabIDs: string[]): Promise<void>;
CloseTab(tabID: string): Promise<void>;
Expand Down Expand Up @@ -2130,6 +2131,21 @@ function makeMockApp(): AppBindings {
mockTabs = [...mockTabs.map((item) => ({ ...item, active: false })), tab];
return { ...tab };
},
async EnsureBlankTab(scope: string, workspaceRoot: string) {
const targetScope = scope === "project" && workspaceRoot ? "project" : "global";
const targetRoot = targetScope === "project" ? workspaceRoot : "";
const existing = mockTabs.find((tab) =>
tab.scope === targetScope &&
(targetScope === "global" || tab.workspaceRoot === targetRoot) &&
!tab.running
);
if (existing) {
setMockActiveTab(existing.id);
return { ...existing, active: true };
}
const topic = await this.CreateTopic(targetScope, targetRoot, "");
return targetScope === "global" ? this.OpenGlobalTab(topic.id) : this.OpenProjectTab(targetRoot, topic.id);
},
async SetActiveTab(_tabID: string) {
setMockActiveTab(_tabID);
const tab = mockTabs.find((item) => item.id === _tabID);
Expand Down
11 changes: 10 additions & 1 deletion desktop/frontend/src/lib/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,15 @@ export function useController() {
return meta;
}, [loadSessionDataForTab]);

// Ensure a blank tab exists for the given scope — reuses an existing one
// or creates a new tab, then loads its session data.
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
setActiveTabId(meta.id);
await loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id));
return meta;
}, [loadSessionDataForTab]);

const closeTab = useCallback(async (tabId: string) => {
try {
await app.CloseTab(tabId);
Expand All @@ -895,7 +904,7 @@ export function useController() {
newSession, clearSession, listSessions, listTrashedSessions, resumeSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, setModel, setEffort,
fetchMemory, remember, forget, saveDoc,
switchTab, openProjectTab, openGlobalTab, closeTab, reorderTabs,
switchTab, openProjectTab, openGlobalTab, ensureBlankTab, closeTab, reorderTabs,
syncActiveTab: syncActiveTabFromBackend,
};
}
Loading
Loading