2026-03-05 18:37:18 +01:00
using System.Text ;
2024-07-13 10:37:57 +02:00
using System.Text.Json ;
using AIStudio.Chat ;
2024-08-21 08:30:01 +02:00
using AIStudio.Dialogs ;
2026-05-24 13:50:42 +02:00
using AIStudio.Tools.AIJobs ;
2026-07-15 12:53:30 +02:00
using AIStudio.Tools.Media ;
using AIStudio.Tools.Services ;
2024-07-13 10:37:57 +02:00
using Microsoft.AspNetCore.Components ;
2024-08-21 08:30:01 +02:00
using DialogOptions = AIStudio . Dialogs . DialogOptions ;
2024-07-13 10:37:57 +02:00
2024-08-21 08:30:01 +02:00
namespace AIStudio.Components ;
2024-07-13 10:37:57 +02:00
2025-04-27 09:06:05 +02:00
public partial class Workspaces : MSGComponentBase
2024-07-13 10:37:57 +02:00
{
[Inject]
2024-08-01 21:53:28 +02:00
private IDialogService DialogService { get ; init ; } = null !;
2024-07-13 10:37:57 +02:00
2024-09-01 20:10:03 +02:00
[Inject]
private ILogger < Workspaces > Logger { get ; init ; } = null !;
2026-05-24 13:50:42 +02:00
[Inject]
private AIJobService AIJobService { get ; init ; } = null !;
2026-07-15 12:53:30 +02:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null !;
2024-09-01 20:10:03 +02:00
2024-07-13 10:37:57 +02:00
[Parameter]
public ChatThread ? CurrentChatThread { get ; set ; }
[Parameter]
public EventCallback < ChatThread > CurrentChatThreadChanged { get ; set ; }
2024-11-02 22:53:02 +01:00
[Parameter]
public bool ExpandRootNodes { get ; set ; } = true ;
2026-06-04 19:34:52 +02:00
[Parameter]
public bool SearchVisible { get ; set ; }
[Parameter]
public EventCallback < bool > SearchVisibleChanged { get ; set ; }
2024-07-13 10:37:57 +02:00
private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement . Bottom ;
2026-03-05 18:37:18 +01:00
private readonly SemaphoreSlim treeLoadingSemaphore = new ( 1 , 1 );
private readonly List < TreeItemData < ITreeItem >> treeItems = [];
private readonly HashSet < Guid > loadingWorkspaceChatLists = [];
2024-07-24 15:17:45 +02:00
2026-03-05 18:37:18 +01:00
private CancellationTokenSource ? prefetchCancellationTokenSource ;
2026-06-04 19:34:52 +02:00
private CancellationTokenSource ? searchCancellationTokenSource ;
2026-03-05 18:37:18 +01:00
private bool isInitialLoading = true ;
private bool isDisposed ;
2026-06-04 19:34:52 +02:00
private bool includeThreadContents ;
private bool isSearchRunning ;
private string searchText = string . Empty ;
private long searchRevision ;
2024-07-13 10:37:57 +02:00
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync ()
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged += this . OnMediaImportStateChanged ;
2025-04-27 16:13:15 +02:00
await base . OnInitializedAsync ();
2026-06-06 10:06:41 +02:00
this . ApplyFilters ([], [ Event . AI_JOB_CHANGED , Event . AI_JOB_FINISHED , Event . CHAT_GENERATION_CHANGED , Event . WORKSPACE_CREATED ]);
2026-08-25 18:53:09 +02:00
this . LoadTreeItemsAsync ( startPrefetch : true ). Observe ( $"{nameof(Workspaces)}: loading the workspace tree" );
2024-07-13 10:37:57 +02:00
}
#endregion
2026-03-05 18:37:18 +01:00
private async Task LoadTreeItemsAsync ( bool startPrefetch = true , bool forceReload = false )
{
2026-06-04 19:34:52 +02:00
var shouldRunSearch = false ;
2026-03-05 18:37:18 +01:00
await this . treeLoadingSemaphore . WaitAsync ();
try
{
if ( this . isDisposed )
return ;
if ( forceReload )
await WorkspaceBehaviour . ForceReloadWorkspaceTreeAsync ();
var snapshot = await WorkspaceBehaviour . GetOrLoadWorkspaceTreeShellAsync ();
2026-06-04 19:34:52 +02:00
if ( this . HasSearchQuery )
shouldRunSearch = true ;
else
this . BuildTreeItems ( snapshot );
2026-03-05 18:37:18 +01:00
this . isInitialLoading = false ;
}
finally
{
this . treeLoadingSemaphore . Release ();
}
2026-06-04 19:34:52 +02:00
if ( shouldRunSearch )
await this . SearchWorkspaceItemsAsync ();
else
await this . SafeStateHasChanged ();
2026-03-05 18:37:18 +01:00
if ( startPrefetch )
await this . StartPrefetchAsync ();
}
2026-06-04 19:34:52 +02:00
private bool HasSearchQuery => this . SearchVisible && ! string . IsNullOrWhiteSpace ( this . searchText );
private string GetAddChatToWorkspaceTooltip ( string workspaceName ) => string . Format ( T ( "Start a new chat in workspace '{0}'" ), workspaceName );
2026-06-06 10:06:41 +02:00
private async Task < Func < string? , string? >> CreateWorkspaceNameValidationAsync ( Guid excludedWorkspaceId = default , string? originalWorkspaceName = null )
{
var snapshot = await WorkspaceBehaviour . GetOrLoadWorkspaceTreeShellAsync ();
return workspaceName =>
{
var normalizedWorkspaceName = WorkspaceBehaviour . NormalizeWorkspaceName ( workspaceName ?? string . Empty );
if ( string . IsNullOrWhiteSpace ( normalizedWorkspaceName ))
return null ;
if (! string . IsNullOrWhiteSpace ( originalWorkspaceName ) &&
string . Equals ( WorkspaceBehaviour . NormalizeWorkspaceName ( originalWorkspaceName ), normalizedWorkspaceName , StringComparison . OrdinalIgnoreCase ))
return null ;
var nameExists = snapshot . Workspaces . Any ( workspace =>
workspace . WorkspaceId != excludedWorkspaceId &&
string . Equals ( WorkspaceBehaviour . NormalizeWorkspaceName ( workspace . Name ), normalizedWorkspaceName , StringComparison . OrdinalIgnoreCase ));
return nameExists ? T ( "There is already a workspace with this name. Please choose a different name." ) : null ;
};
}
2026-03-05 18:37:18 +01:00
private void BuildTreeItems ( WorkspaceTreeCacheSnapshot snapshot )
2024-07-13 10:37:57 +02:00
{
2025-06-27 20:52:33 +02:00
this . treeItems . Clear ();
2026-03-05 18:37:18 +01:00
var workspaceChildren = new List < TreeItemData < ITreeItem >>();
foreach ( var workspace in snapshot . Workspaces )
workspaceChildren . Add ( this . CreateWorkspaceTreeItem ( workspace ));
workspaceChildren . Add ( new TreeItemData < ITreeItem >
{
Expandable = false ,
Value = new TreeButton ( WorkspaceBranch . WORKSPACES , 1 , T ( "Add workspace" ), Icons . Material . Filled . LibraryAdd , this . AddWorkspaceAsync ),
});
2025-06-27 20:52:33 +02:00
this . treeItems . Add ( new TreeItemData < ITreeItem >
2024-07-13 10:37:57 +02:00
{
2024-11-02 22:53:02 +01:00
Expanded = this . ExpandRootNodes ,
2024-07-13 10:37:57 +02:00
Expandable = true ,
2024-07-24 15:17:45 +02:00
Value = new TreeItemData
{
Depth = 0 ,
Branch = WorkspaceBranch . WORKSPACES ,
2025-04-27 09:06:05 +02:00
Text = T ( "Workspaces" ),
2024-07-24 15:17:45 +02:00
Icon = Icons . Material . Filled . Folder ,
Expandable = true ,
Path = "root" ,
2026-03-05 18:37:18 +01:00
Children = workspaceChildren ,
2024-07-24 15:17:45 +02:00
},
2025-06-27 20:52:33 +02:00
});
this . treeItems . Add ( new TreeItemData < ITreeItem >
{
Expandable = false ,
Value = new TreeDivider (),
});
2026-03-05 18:37:18 +01:00
var temporaryChatsChildren = new List < TreeItemData < ITreeItem >>();
foreach ( var temporaryChat in snapshot . TemporaryChats . OrderByDescending ( x => x . LastEditTime ))
2026-05-24 13:50:42 +02:00
temporaryChatsChildren . Add ( this . CreateChatTreeItem ( temporaryChat , WorkspaceBranch . TEMPORARY_CHATS , depth : 1 , icon : Icons . Material . Filled . Timer ));
2026-03-05 18:37:18 +01:00
2025-06-27 20:52:33 +02:00
this . treeItems . Add ( new TreeItemData < ITreeItem >
2024-07-13 10:37:57 +02:00
{
2024-11-02 22:53:02 +01:00
Expanded = this . ExpandRootNodes ,
2024-07-13 10:37:57 +02:00
Expandable = true ,
2024-07-24 15:17:45 +02:00
Value = new TreeItemData
{
Depth = 0 ,
Branch = WorkspaceBranch . TEMPORARY_CHATS ,
2025-04-27 09:06:05 +02:00
Text = T ( "Disappearing Chats" ),
2024-07-24 15:17:45 +02:00
Icon = Icons . Material . Filled . Timer ,
Expandable = true ,
Path = "temp" ,
2026-03-05 18:37:18 +01:00
Children = temporaryChatsChildren ,
2024-07-24 15:17:45 +02:00
},
2024-07-13 10:37:57 +02:00
});
}
2026-03-05 18:37:18 +01:00
private TreeItemData < ITreeItem > CreateWorkspaceTreeItem ( WorkspaceTreeWorkspace workspace )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
var children = new List < TreeItemData < ITreeItem >>();
if ( workspace . ChatsLoaded )
{
foreach ( var workspaceChat in workspace . Chats . OrderByDescending ( x => x . LastEditTime ))
2026-05-24 13:50:42 +02:00
children . Add ( this . CreateChatTreeItem ( workspaceChat , WorkspaceBranch . WORKSPACES , depth : 2 , icon : Icons . Material . Filled . Chat ));
2026-03-05 18:37:18 +01:00
}
else if ( this . loadingWorkspaceChatLists . Contains ( workspace . WorkspaceId ))
children . AddRange ( this . CreateLoadingRows ( workspace . WorkspacePath ));
children . Add ( new TreeItemData < ITreeItem >
{
Expandable = false ,
Value = new TreeButton ( WorkspaceBranch . WORKSPACES , 2 , T ( "Add chat" ), Icons . Material . Filled . AddComment , () => this . AddChatAsync ( workspace . WorkspacePath )),
});
return new TreeItemData < ITreeItem >
{
Expandable = true ,
Value = new TreeItemData
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
Type = TreeItemType . WORKSPACE ,
2024-08-19 20:19:55 +02:00
Depth = 1 ,
2026-03-05 18:37:18 +01:00
Branch = WorkspaceBranch . WORKSPACES ,
Text = workspace . Name ,
Icon = Icons . Material . Filled . Description ,
Expandable = true ,
Path = workspace . WorkspacePath ,
Children = children ,
},
};
}
private IReadOnlyCollection < TreeItemData < ITreeItem >> CreateLoadingRows ( string workspacePath )
{
return
[
this.CreateLoadingTreeItem(workspacePath, "loading_1"),
this.CreateLoadingTreeItem(workspacePath, "loading_2"),
this.CreateLoadingTreeItem(workspacePath, "loading_3"),
] ;
}
private TreeItemData < ITreeItem > CreateLoadingTreeItem ( string workspacePath , string suffix )
{
return new TreeItemData < ITreeItem >
{
Expandable = false ,
Value = new TreeItemData
{
Type = TreeItemType . LOADING ,
Depth = 2 ,
Branch = WorkspaceBranch . WORKSPACES ,
Text = T ( "Loading chats..." ),
Icon = Icons . Material . Filled . HourglassTop ,
2024-07-13 10:37:57 +02:00
Expandable = false ,
2026-03-05 18:37:18 +01:00
Path = Path . Join ( workspacePath , suffix ),
},
};
}
2026-05-24 13:50:42 +02:00
private TreeItemData < ITreeItem > CreateChatTreeItem ( WorkspaceTreeChat chat , WorkspaceBranch branch , int depth , string icon )
2026-03-05 18:37:18 +01:00
{
return new TreeItemData < ITreeItem >
2024-08-19 20:19:55 +02:00
{
Expandable = false ,
2026-03-05 18:37:18 +01:00
Value = new TreeItemData
{
Type = TreeItemType . CHAT ,
Depth = depth ,
Branch = branch ,
Text = chat . Name ,
Icon = icon ,
2026-05-24 13:50:42 +02:00
DefaultIcon = icon ,
2026-03-05 18:37:18 +01:00
Expandable = false ,
Path = chat . ChatPath ,
2026-05-24 13:50:42 +02:00
ChatId = chat . ChatId ,
WorkspaceId = chat . WorkspaceId ,
2026-03-05 18:37:18 +01:00
LastEditTime = chat . LastEditTime ,
},
};
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
2026-06-04 19:34:52 +02:00
private void BuildSearchTreeItems ( WorkspaceSearchSnapshot snapshot )
{
this . treeItems . Clear ();
if ( snapshot . Workspaces . Count == 0 && snapshot . TemporaryChats . Count == 0 )
{
this . treeItems . Add ( new TreeItemData < ITreeItem >
{
Expandable = false ,
Value = new TreeItemData
{
Depth = 0 ,
Branch = WorkspaceBranch . NONE ,
Text = T ( "No chats found" ),
Icon = Icons . Material . Filled . Search ,
Expandable = false ,
Path = "search_empty" ,
},
});
return ;
}
if ( snapshot . Workspaces . Count > 0 )
{
var workspaceChildren = new List < TreeItemData < ITreeItem >>();
foreach ( var workspace in snapshot . Workspaces )
workspaceChildren . Add ( this . CreateSearchWorkspaceTreeItem ( workspace ));
this . treeItems . Add ( new TreeItemData < ITreeItem >
{
Expanded = true ,
Expandable = true ,
Value = new TreeItemData
{
Depth = 0 ,
Branch = WorkspaceBranch . WORKSPACES ,
Text = T ( "Workspaces" ),
Icon = Icons . Material . Filled . Folder ,
Expandable = true ,
Path = "search_workspaces" ,
Children = workspaceChildren ,
},
});
}
if ( snapshot . Workspaces . Count > 0 && snapshot . TemporaryChats . Count > 0 )
{
this . treeItems . Add ( new TreeItemData < ITreeItem >
{
Expandable = false ,
Value = new TreeDivider (),
});
}
if ( snapshot . TemporaryChats . Count > 0 )
{
var temporaryChatsChildren = new List < TreeItemData < ITreeItem >>();
foreach ( var temporaryChat in snapshot . TemporaryChats )
temporaryChatsChildren . Add ( this . CreateChatTreeItem ( temporaryChat . Chat , WorkspaceBranch . TEMPORARY_CHATS , depth : 1 , icon : Icons . Material . Filled . Timer ));
this . treeItems . Add ( new TreeItemData < ITreeItem >
{
Expanded = true ,
Expandable = true ,
Value = new TreeItemData
{
Depth = 0 ,
Branch = WorkspaceBranch . TEMPORARY_CHATS ,
Text = T ( "Disappearing Chats" ),
Icon = Icons . Material . Filled . Timer ,
Expandable = true ,
Path = "search_temp" ,
Children = temporaryChatsChildren ,
},
});
}
}
private TreeItemData < ITreeItem > CreateSearchWorkspaceTreeItem ( WorkspaceSearchWorkspace workspace )
{
var children = new List < TreeItemData < ITreeItem >>();
foreach ( var chat in workspace . Chats )
children . Add ( this . CreateChatTreeItem ( chat . Chat , WorkspaceBranch . WORKSPACES , depth : 2 , icon : Icons . Material . Filled . Chat ));
return new TreeItemData < ITreeItem >
{
Expanded = true ,
Expandable = true ,
Value = new TreeItemData
{
Type = TreeItemType . WORKSPACE ,
Depth = 1 ,
Branch = WorkspaceBranch . WORKSPACES ,
Text = workspace . Name ,
Icon = Icons . Material . Filled . Description ,
Expandable = true ,
Path = workspace . WorkspacePath ,
Children = children ,
},
};
}
2026-05-24 13:50:42 +02:00
private string GetTreeItemIcon ( TreeItemData treeItem )
{
if ( treeItem . Type is not TreeItemType . CHAT )
return treeItem . Icon ;
var defaultIcon = string . IsNullOrWhiteSpace ( treeItem . DefaultIcon ) ? treeItem . Icon : treeItem . DefaultIcon ;
return this . GetChatTreeIcon ( treeItem . ChatId , defaultIcon );
}
private bool IsChatTreeItemBusy ( TreeItemData treeItem )
{
2026-07-15 12:53:30 +02:00
return treeItem . Type is TreeItemType . CHAT
&& ( this . AIJobService . IsChatGenerationActive ( treeItem . ChatId )
|| this . MediaTranscriptionService . IsBusy ( MediaImportOwner . ForChat ( treeItem . ChatId )));
2026-05-24 13:50:42 +02:00
}
2026-06-08 12:52:48 +02:00
private string GetChatTreeItemTextStyle ( TreeItemData treeItem )
{
2026-07-15 12:53:30 +02:00
var status = this . MediaTranscriptionService . GetSnapshot ( MediaImportOwner . ForChat ( treeItem . ChatId ))?. Status ;
var color = status switch
{
MediaImportStatus . QUEUED or MediaImportStatus . RUNNING or MediaImportStatus . CANCELING => " color: var(--mud-palette-info);" ,
MediaImportStatus . SUCCEEDED => " color: var(--mud-palette-success);" ,
MediaImportStatus . WARNING => " color: var(--mud-palette-warning);" ,
MediaImportStatus . FAILED => " color: var(--mud-palette-error);" ,
MediaImportStatus . CANCELLED => " color: var(--mud-palette-warning);" ,
_ => string . Empty ,
};
var weight = this . IsCurrentChatTreeItem ( treeItem ) ? " font-weight: 700;" : string . Empty ;
return $"justify-self: start;{weight}{color}" ;
2026-06-08 12:52:48 +02:00
}
private bool IsCurrentChatTreeItem ( TreeItemData treeItem )
{
return treeItem . Type is TreeItemType . CHAT
&& this . CurrentChatThread is not null
&& treeItem . ChatId == this . CurrentChatThread . ChatId
&& treeItem . WorkspaceId == this . CurrentChatThread . WorkspaceId ;
}
2026-05-24 13:50:42 +02:00
private string GetChatTreeIcon ( Guid chatId , string defaultIcon )
{
2026-07-15 12:53:30 +02:00
var mediaStatus = this . MediaTranscriptionService . GetSnapshot ( MediaImportOwner . ForChat ( chatId ))?. Status ;
if ( mediaStatus is not null )
{
return mediaStatus switch
{
MediaImportStatus . QUEUED => Icons . Material . Filled . HourglassTop ,
MediaImportStatus . RUNNING or MediaImportStatus . CANCELING => Icons . Material . Filled . ChangeCircle ,
MediaImportStatus . SUCCEEDED => Icons . Material . Filled . TaskAlt ,
MediaImportStatus . WARNING => Icons . Material . Filled . WarningAmber ,
MediaImportStatus . FAILED => Icons . Material . Filled . Error ,
MediaImportStatus . CANCELLED => Icons . Material . Filled . Cancel ,
_ => defaultIcon ,
};
}
2026-05-24 13:50:42 +02:00
var snapshot = this . AIJobService . TryGetChatSnapshot ( chatId );
2026-05-25 20:48:26 +02:00
if ( snapshot is null || ! snapshot . IsActive )
return defaultIcon ;
return snapshot . Status switch
2026-05-24 13:50:42 +02:00
{
AIJobStatus . WAITING_FOR_REMOTE => Icons . Material . Filled . HourglassTop ,
AIJobStatus . RUNNING => Icons . Material . Filled . ChangeCircle ,
_ => defaultIcon ,
};
}
2026-07-15 12:53:30 +02:00
private void OnMediaImportStateChanged ( MediaImportOwner owner )
{
if ( owner . Kind is MediaImportOwnerKind . CHAT )
2026-08-25 18:53:09 +02:00
this . SafeStateHasChanged (). Observe ( $"{nameof(Workspaces)}: rendering a media import change" );
2026-07-15 12:53:30 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task SafeStateHasChanged ()
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
if ( this . isDisposed )
return ;
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
await this . InvokeAsync ( this . StateHasChanged );
}
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
private async Task StartPrefetchAsync ()
{
if ( this . prefetchCancellationTokenSource is not null )
{
await this . prefetchCancellationTokenSource . CancelAsync ();
this . prefetchCancellationTokenSource . Dispose ();
}
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
this . prefetchCancellationTokenSource = new CancellationTokenSource ();
await this . PrefetchWorkspaceChatsAsync ( this . prefetchCancellationTokenSource . Token );
}
private async Task PrefetchWorkspaceChatsAsync ( CancellationToken cancellationToken )
{
try
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . TryPrefetchRemainingChatsAsync ( async _ =>
2025-08-28 16:44:32 +02:00
{
2026-03-05 18:37:18 +01:00
if ( this . isDisposed || cancellationToken . IsCancellationRequested )
return ;
await this . LoadTreeItemsAsync ( startPrefetch : false );
}, cancellationToken );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
catch ( OperationCanceledException )
2024-07-24 15:17:45 +02:00
{
2026-03-05 18:37:18 +01:00
// Expected when the component is hidden or disposed.
}
catch ( Exception ex )
{
this . Logger . LogWarning ( ex , "Failed while prefetching workspace chats." );
}
}
2026-06-04 19:34:52 +02:00
public async Task ToggleSearchAsync ()
{
var searchVisible = ! this . SearchVisible ;
this . SearchVisible = searchVisible ;
await this . SearchVisibleChanged . InvokeAsync ( searchVisible );
if ( this . SearchVisible )
{
await this . SafeStateHasChanged ();
return ;
}
await this . CancelSearchAsync ();
this . searchText = string . Empty ;
this . isSearchRunning = false ;
await this . LoadTreeItemsAsync ( startPrefetch : false );
}
private async Task CancelSearchAsync ()
{
this . searchRevision ++;
if ( this . searchCancellationTokenSource is not null )
{
await this . searchCancellationTokenSource . CancelAsync ();
this . searchCancellationTokenSource . Dispose ();
this . searchCancellationTokenSource = null ;
}
}
private async Task OnSearchTextChanged ( string value )
{
this . searchText = value ;
if ( string . IsNullOrWhiteSpace ( this . searchText ))
{
await this . CancelSearchAsync ();
this . isSearchRunning = false ;
await this . LoadTreeItemsAsync ( startPrefetch : false );
return ;
}
await this . SearchWorkspaceItemsAsync ();
}
private async Task IncludeThreadContentsChanged ( bool value )
{
this . includeThreadContents = value ;
if ( this . HasSearchQuery )
await this . SearchWorkspaceItemsAsync ();
}
private async Task ClearSearchAsync ()
{
this . searchText = string . Empty ;
await this . CancelSearchAsync ();
this . isSearchRunning = false ;
await this . LoadTreeItemsAsync ( startPrefetch : false );
}
private async Task SearchWorkspaceItemsAsync ()
{
await this . CancelSearchAsync ();
var text = this . searchText ;
if ( string . IsNullOrWhiteSpace ( text ))
return ;
this . searchCancellationTokenSource = new CancellationTokenSource ();
var token = this . searchCancellationTokenSource . Token ;
var revision = ++ this . searchRevision ;
this . isSearchRunning = true ;
await this . SafeStateHasChanged ();
try
{
var snapshot = await WorkspaceBehaviour . SearchWorkspaceChatsAsync ( text , this . includeThreadContents , token );
if ( this . isDisposed || token . IsCancellationRequested || revision != this . searchRevision )
return ;
this . BuildSearchTreeItems ( snapshot );
}
catch ( OperationCanceledException )
{
// Expected when the user keeps typing or hides the search row.
}
catch ( Exception ex )
{
this . Logger . LogWarning ( ex , "Failed while searching workspace chats." );
this . BuildSearchTreeItems ( new ([], []));
}
finally
{
if ( revision == this . searchRevision )
{
this . isSearchRunning = false ;
await this . SafeStateHasChanged ();
}
}
}
2026-03-05 18:37:18 +01:00
private async Task OnWorkspaceClicked ( TreeItemData treeItem )
{
if ( treeItem . Type is not TreeItemType . WORKSPACE )
return ;
if (! Guid . TryParse ( Path . GetFileName ( treeItem . Path ), out var workspaceId ))
return ;
await this . EnsureWorkspaceChatsLoadedAsync ( workspaceId );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task EnsureWorkspaceChatsLoadedAsync ( Guid workspaceId )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
var snapshot = await WorkspaceBehaviour . GetOrLoadWorkspaceTreeShellAsync ();
var hasWorkspace = false ;
var chatsLoaded = false ;
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
foreach ( var workspace in snapshot . Workspaces )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
if ( workspace . WorkspaceId != workspaceId )
continue ;
hasWorkspace = true ;
chatsLoaded = workspace . ChatsLoaded ;
break ;
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
if (! hasWorkspace || chatsLoaded || ! this . loadingWorkspaceChatLists . Add ( workspaceId ))
return ;
await this . LoadTreeItemsAsync ( startPrefetch : false );
try
2024-08-19 20:19:55 +02:00
{
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . GetWorkspaceChatsAsync ( workspaceId );
}
finally
2024-07-24 15:17:45 +02:00
{
2026-03-05 18:37:18 +01:00
this . loadingWorkspaceChatLists . Remove ( workspaceId );
}
await this . LoadTreeItemsAsync ( startPrefetch : false );
}
public async Task ForceRefreshFromDiskAsync ()
{
if ( this . prefetchCancellationTokenSource is not null )
{
await this . prefetchCancellationTokenSource . CancelAsync ();
this . prefetchCancellationTokenSource . Dispose ();
this . prefetchCancellationTokenSource = null ;
}
2024-07-24 15:17:45 +02:00
2026-03-05 18:37:18 +01:00
this . loadingWorkspaceChatLists . Clear ();
this . isInitialLoading = true ;
await this . SafeStateHasChanged ();
await this . LoadTreeItemsAsync ( startPrefetch : true , forceReload : true );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
public async Task StoreChatAsync ( ChatThread chat , bool reloadTreeItems = false )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . StoreChatAsync ( chat );
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
if ( reloadTreeItems )
this . loadingWorkspaceChatLists . Clear ();
2024-10-28 15:41:00 +01:00
2026-03-05 18:37:18 +01:00
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task < ChatThread ?> LoadChatAsync ( string? chatPath , bool switchToChat )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
if ( string . IsNullOrWhiteSpace ( chatPath ))
2024-07-13 10:37:57 +02:00
return null ;
2026-03-05 18:37:18 +01:00
if (! Directory . Exists ( chatPath ))
2024-07-13 10:37:57 +02:00
return null ;
if ( switchToChat && await MessageBus . INSTANCE . SendMessageUseFirstResult < bool , bool >( this , Event . HAS_CHAT_UNSAVED_CHANGES ))
{
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < ConfirmDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , T ( "Are you sure you want to load another chat? All unsaved changes will be lost." ) },
2024-07-13 10:37:57 +02:00
};
2026-03-05 18:37:18 +01:00
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Load Chat" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return null ;
}
try
{
var chatData = await File . ReadAllTextAsync ( Path . Join ( chatPath , "thread.json" ), Encoding . UTF8 );
2024-11-15 21:22:57 +01:00
var chat = JsonSerializer . Deserialize < ChatThread >( chatData , WorkspaceBehaviour . JSON_OPTIONS );
2026-05-24 13:50:42 +02:00
if ( chat is not null )
chat = this . AIJobService . TryGetLiveChatThread ( chat . ChatId ) ?? chat ;
2024-07-13 10:37:57 +02:00
if ( switchToChat )
{
this . CurrentChatThread = chat ;
await this . CurrentChatThreadChanged . InvokeAsync ( this . CurrentChatThread );
}
return chat ;
}
catch ( Exception e )
{
2024-09-01 20:10:03 +02:00
this . Logger . LogError ( $"Failed to load chat from '{chatPath}': {e.Message}" );
2024-07-13 10:37:57 +02:00
}
return null ;
}
2026-03-05 18:37:18 +01:00
public async Task DeleteChatAsync ( string? chatPath , bool askForConfirmation = true , bool unloadChat = true )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
var chat = await this . LoadChatAsync ( chatPath , false );
2024-07-13 10:37:57 +02:00
if ( chat is null )
return ;
2026-07-15 12:53:30 +02:00
var mediaOwner = MediaImportOwner . ForChat ( chat . ChatId );
if ( this . AIJobService . IsChatGenerationActive ( chat . ChatId ) || this . MediaTranscriptionService . IsBusy ( mediaOwner ))
2026-05-24 13:50:42 +02:00
return ;
2024-07-13 10:37:57 +02:00
if ( askForConfirmation )
{
2026-03-05 18:37:18 +01:00
var workspaceName = await WorkspaceBehaviour . LoadWorkspaceNameAsync ( chat . WorkspaceId );
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < ConfirmDialog >
2024-07-13 10:37:57 +02:00
{
{
2025-08-28 18:51:44 +02:00
x => x . Message , ( chat . WorkspaceId == Guid . Empty ) switch
2024-07-13 10:37:57 +02:00
{
2025-04-27 09:06:05 +02:00
true => string . Format ( T ( "Are you sure you want to delete the temporary chat '{0}'?" ), chat . Name ),
false => string . Format ( T ( "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?" ), chat . Name , workspaceName ),
2024-07-13 10:37:57 +02:00
}
},
};
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Delete Chat" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
}
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . DeleteChatAsync ( this . DialogService , chat . WorkspaceId , chat . ChatId , askForConfirmation : false );
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . ClearOwnerState ( mediaOwner );
2026-03-05 18:37:18 +01:00
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
if ( unloadChat && this . CurrentChatThread ?. ChatId == chat . ChatId )
2024-07-13 10:37:57 +02:00
{
this . CurrentChatThread = null ;
await this . CurrentChatThreadChanged . InvokeAsync ( this . CurrentChatThread );
}
}
2026-03-05 18:37:18 +01:00
private async Task RenameChatAsync ( string? chatPath )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
var chat = await this . LoadChatAsync ( chatPath , false );
2024-07-13 10:37:57 +02:00
if ( chat is null )
return ;
2026-05-24 13:50:42 +02:00
if ( this . AIJobService . IsChatGenerationActive ( chat . ChatId ))
return ;
2024-07-13 10:37:57 +02:00
2025-08-28 18:16:30 +02:00
var dialogParameters = new DialogParameters < SingleInputDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:16:30 +02:00
{ x => x . Message , string . Format ( T ( "Please enter a new or edit the name for your chat '{0}':" ), chat . Name ) },
2025-08-28 18:31:50 +02:00
{ x => x . InputHeaderText , T ( "Chat Name" ) },
2025-08-28 18:16:30 +02:00
{ x => x . UserInput , chat . Name },
{ x => x . ConfirmText , T ( "Rename" ) },
{ x => x . ConfirmColor , Color . Info },
{ x => x . AllowEmptyInput , false },
{ x => x . EmptyInputErrorMessage , T ( "Please enter a chat name." ) },
2024-07-13 10:37:57 +02:00
};
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < SingleInputDialog >( T ( "Rename Chat" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
chat . Name = ( dialogResult . Data as string )!;
2026-03-05 18:37:18 +01:00
if ( this . CurrentChatThread ?. ChatId == chat . ChatId )
2025-05-17 20:35:51 +02:00
{
this . CurrentChatThread . Name = chat . Name ;
await this . CurrentChatThreadChanged . InvokeAsync ( this . CurrentChatThread );
}
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . StoreChatAsync ( chat );
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task RenameWorkspaceAsync ( string? workspacePath )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
if ( workspacePath is null )
2024-07-13 10:37:57 +02:00
return ;
var workspaceId = Guid . Parse ( Path . GetFileName ( workspacePath ));
2026-03-05 18:37:18 +01:00
var workspaceName = await WorkspaceBehaviour . LoadWorkspaceNameAsync ( workspaceId );
2025-08-28 18:16:30 +02:00
var dialogParameters = new DialogParameters < SingleInputDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:16:30 +02:00
{ x => x . Message , string . Format ( T ( "Please enter a new or edit the name for your workspace '{0}':" ), workspaceName ) },
2025-08-28 18:31:50 +02:00
{ x => x . InputHeaderText , T ( "Workspace Name" ) },
2025-08-28 18:16:30 +02:00
{ x => x . UserInput , workspaceName },
{ x => x . ConfirmText , T ( "Rename" ) },
{ x => x . ConfirmColor , Color . Info },
{ x => x . AllowEmptyInput , false },
{ x => x . EmptyInputErrorMessage , T ( "Please enter a workspace name." ) },
2026-06-06 10:06:41 +02:00
{ x => x . AdditionalValidation , await this . CreateWorkspaceNameValidationAsync ( workspaceId , workspaceName ) },
2024-07-13 10:37:57 +02:00
};
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < SingleInputDialog >( T ( "Rename Workspace" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
var alteredWorkspaceName = ( dialogResult . Data as string )!;
2026-06-06 10:06:41 +02:00
if (! await WorkspaceBehaviour . RenameWorkspaceAsync ( workspaceId , alteredWorkspaceName ))
return ;
await this . SendMessage ( Event . WORKSPACE_RENAMED , workspaceId );
2026-03-05 18:37:18 +01:00
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task AddWorkspaceAsync ()
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:16:30 +02:00
var dialogParameters = new DialogParameters < SingleInputDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:16:30 +02:00
{ x => x . Message , T ( "Please name your workspace:" ) },
2025-08-28 18:31:50 +02:00
{ x => x . InputHeaderText , T ( "Workspace Name" ) },
2025-08-28 18:16:30 +02:00
{ x => x . UserInput , string . Empty },
{ x => x . ConfirmText , T ( "Add workspace" ) },
{ x => x . ConfirmColor , Color . Info },
{ x => x . AllowEmptyInput , false },
{ x => x . EmptyInputErrorMessage , T ( "Please enter a workspace name." ) },
2026-06-06 10:06:41 +02:00
{ x => x . AdditionalValidation , await this . CreateWorkspaceNameValidationAsync () },
2024-07-13 10:37:57 +02:00
};
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < SingleInputDialog >( T ( "Add Workspace" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
2026-03-05 18:37:18 +01:00
var workspaceName = ( dialogResult . Data as string )!;
2026-06-06 10:06:41 +02:00
var result = await WorkspaceBehaviour . TryCreateWorkspaceAsync ( workspaceName );
if (! result . Success )
return ;
2024-07-13 10:37:57 +02:00
2026-03-05 18:37:18 +01:00
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2024-10-28 15:41:00 +01:00
2026-03-05 18:37:18 +01:00
private async Task DeleteWorkspaceAsync ( string? workspacePath )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
if ( workspacePath is null )
2024-07-13 10:37:57 +02:00
return ;
var workspaceId = Guid . Parse ( Path . GetFileName ( workspacePath ));
2026-03-05 18:37:18 +01:00
var workspaceName = await WorkspaceBehaviour . LoadWorkspaceNameAsync ( workspaceId );
2024-07-13 10:37:57 +02:00
var chatCount = Directory . EnumerateDirectories ( workspacePath ). Count ();
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < ConfirmDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , string . Format ( T ( "Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace." ), workspaceName , chatCount ) },
2024-07-13 10:37:57 +02:00
};
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Delete Workspace" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
Directory . Delete ( workspacePath , true );
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . RemoveWorkspaceFromCacheAsync ( workspaceId );
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task MoveChatAsync ( string? chatPath )
2024-07-13 10:37:57 +02:00
{
2026-03-05 18:37:18 +01:00
var chat = await this . LoadChatAsync ( chatPath , false );
2024-07-13 10:37:57 +02:00
if ( chat is null )
return ;
2026-05-24 13:50:42 +02:00
if ( this . AIJobService . IsChatGenerationActive ( chat . ChatId ))
return ;
2024-07-13 10:37:57 +02:00
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < WorkspaceSelectionDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , T ( "Please select the workspace where you want to move the chat to." ) },
{ x => x . SelectedWorkspace , chat . WorkspaceId },
{ x => x . ConfirmText , T ( "Move chat" ) },
2024-07-13 10:37:57 +02:00
};
2026-06-06 10:06:41 +02:00
var dialogReference = await this . DialogService . ShowAsync < WorkspaceSelectionDialog >( T ( "Move Chat to Workspace" ), dialogParameters , DialogOptions . FULLSCREEN_MANUAL_ESCAPE );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
2025-04-27 09:06:05 +02:00
var workspaceId = dialogResult . Data is Guid id ? id : Guid . Empty ;
2024-07-13 10:37:57 +02:00
if ( workspaceId == Guid . Empty )
return ;
2026-07-15 12:53:30 +02:00
await WorkspaceBehaviour . MoveChatAsync ( chat , workspaceId );
2024-07-13 10:37:57 +02:00
if ( this . CurrentChatThread ?. ChatId == chat . ChatId )
{
this . CurrentChatThread = chat ;
await this . CurrentChatThreadChanged . InvokeAsync ( this . CurrentChatThread );
}
2026-07-15 12:53:30 +02:00
2026-03-05 18:37:18 +01:00
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2026-03-05 18:37:18 +01:00
private async Task AddChatAsync ( string workspacePath )
2024-07-13 10:37:57 +02:00
{
if ( await MessageBus . INSTANCE . SendMessageUseFirstResult < bool , bool >( this , Event . HAS_CHAT_UNSAVED_CHANGES ))
{
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < ConfirmDialog >
2024-07-13 10:37:57 +02:00
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , T ( "Are you sure you want to create a another chat? All unsaved changes will be lost." ) },
2024-07-13 10:37:57 +02:00
};
2025-04-27 09:06:05 +02:00
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Create Chat" ), dialogParameters , DialogOptions . FULLSCREEN );
2024-07-13 10:37:57 +02:00
var dialogResult = await dialogReference . Result ;
2024-07-24 15:17:45 +02:00
if ( dialogResult is null || dialogResult . Canceled )
2024-07-13 10:37:57 +02:00
return ;
}
var workspaceId = Guid . Parse ( Path . GetFileName ( workspacePath ));
var chat = new ChatThread
{
WorkspaceId = workspaceId ,
ChatId = Guid . NewGuid (),
Name = string . Empty ,
2024-11-15 21:22:57 +01:00
SystemPrompt = SystemPrompts . DEFAULT ,
2024-07-13 10:37:57 +02:00
Blocks = [],
};
var chatPath = Path . Join ( workspacePath , chat . ChatId . ToString ());
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . StoreChatAsync ( chat );
await this . LoadChatAsync ( chatPath , switchToChat : true );
await this . LoadTreeItemsAsync ( startPrefetch : false );
2024-07-13 10:37:57 +02:00
}
2025-04-27 16:13:15 +02:00
#region Overrides of MSGComponentBase
protected override async Task ProcessIncomingMessage < T >( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
{
switch ( triggeredEvent )
{
case Event . PLUGINS_RELOADED :
2026-03-05 18:37:18 +01:00
await this . ForceRefreshFromDiskAsync ();
2025-04-27 16:13:15 +02:00
break ;
2026-05-24 13:50:42 +02:00
2026-06-06 10:06:41 +02:00
case Event . WORKSPACE_CREATED :
await this . LoadTreeItemsAsync ( startPrefetch : false );
break ;
2026-05-24 13:50:42 +02:00
case Event . AI_JOB_CHANGED :
case Event . AI_JOB_FINISHED :
case Event . CHAT_GENERATION_CHANGED :
await this . SafeStateHasChanged ();
break ;
2025-04-27 16:13:15 +02:00
}
}
2026-03-05 18:37:18 +01:00
protected override void DisposeResources ()
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged -= this . OnMediaImportStateChanged ;
2026-03-05 18:37:18 +01:00
this . isDisposed = true ;
this . prefetchCancellationTokenSource ?. Cancel ();
this . prefetchCancellationTokenSource ?. Dispose ();
this . prefetchCancellationTokenSource = null ;
2026-06-04 19:34:52 +02:00
this . searchCancellationTokenSource ?. Cancel ();
this . searchCancellationTokenSource ?. Dispose ();
this . searchCancellationTokenSource = null ;
2026-03-05 18:37:18 +01:00
base . DisposeResources ();
}
2025-04-27 16:13:15 +02:00
#endregion
2026-06-04 19:34:52 +02:00
}