Skip to content

Commit e071d13

Browse files
committed
added checkbox for selecting a previous session for the given workspace
1 parent 0bdee96 commit e071d13

File tree

5 files changed

+763
-18
lines changed

5 files changed

+763
-18
lines changed
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Text;
7+
using System.Text.RegularExpressions;
8+
using Newtonsoft.Json.Linq;
9+
10+
namespace SemanticDeveloper.Services;
11+
12+
public static class CodexSessionService
13+
{
14+
public sealed class SessionInfo
15+
{
16+
public required string Id { get; init; }
17+
public required string WorkspacePath { get; init; }
18+
public required DateTimeOffset StartedAt { get; init; }
19+
public string FirstUserMessage { get; init; } = string.Empty;
20+
public string RolloutPath { get; init; } = string.Empty;
21+
}
22+
23+
public static List<SessionInfo> GetSessionsForWorkspace(string workspacePath, int maxResults = 40)
24+
{
25+
var results = new List<SessionInfo>();
26+
if (string.IsNullOrWhiteSpace(workspacePath))
27+
return results;
28+
29+
var normalizedWorkspace = NormalizePath(workspacePath);
30+
if (string.IsNullOrEmpty(normalizedWorkspace))
31+
return results;
32+
33+
var root = GetSessionsRoot();
34+
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
35+
return results;
36+
37+
foreach (var yearDir in EnumerateDirectoriesDescending(root))
38+
{
39+
foreach (var monthDir in EnumerateDirectoriesDescending(yearDir))
40+
{
41+
foreach (var dayDir in EnumerateDirectoriesDescending(monthDir))
42+
{
43+
foreach (var file in EnumerateFilesDescending(dayDir, "*.jsonl"))
44+
{
45+
if (!TryReadSessionMetadata(file, out var info))
46+
continue;
47+
48+
if (!PathsEqual(info.WorkspacePath, normalizedWorkspace))
49+
continue;
50+
51+
results.Add(info);
52+
if (results.Count >= maxResults)
53+
return OrderByNewest(results);
54+
}
55+
}
56+
}
57+
}
58+
59+
return OrderByNewest(results);
60+
}
61+
62+
private static bool TryReadSessionMetadata(string filePath, out SessionInfo info)
63+
{
64+
info = default!;
65+
try
66+
{
67+
using var reader = File.OpenText(filePath);
68+
string? line;
69+
string? id = null;
70+
string? cwd = null;
71+
DateTimeOffset? timestamp = null;
72+
string? preferredMessage = null;
73+
string? fallbackMessage = null;
74+
75+
while ((line = reader.ReadLine()) != null)
76+
{
77+
if (string.IsNullOrWhiteSpace(line))
78+
continue;
79+
80+
JObject obj;
81+
try
82+
{
83+
obj = JObject.Parse(line);
84+
}
85+
catch
86+
{
87+
continue;
88+
}
89+
90+
var type = obj["type"]?.ToString();
91+
if (type == "session_meta" && obj["payload"] is JObject payload)
92+
{
93+
id ??= payload["id"]?.ToString();
94+
var ts = payload["timestamp"]?.ToString();
95+
if (!timestamp.HasValue && !string.IsNullOrWhiteSpace(ts) && DateTimeOffset.TryParse(ts, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedTs))
96+
timestamp = parsedTs;
97+
var workspace = payload["cwd"]?.ToString();
98+
if (!string.IsNullOrWhiteSpace(workspace))
99+
cwd ??= NormalizePath(workspace);
100+
}
101+
else if (type == "response_item" && obj["payload"] is JObject resp)
102+
{
103+
var payloadType = resp["type"]?.ToString();
104+
var role = resp["role"]?.ToString();
105+
if (payloadType == "message" && string.Equals(role, "user", StringComparison.OrdinalIgnoreCase))
106+
{
107+
var raw = ExtractMessageText(resp["content"] as JArray);
108+
if (string.IsNullOrWhiteSpace(raw))
109+
continue;
110+
111+
var sanitized = SanitizeUserMessage(raw);
112+
if (string.IsNullOrWhiteSpace(sanitized))
113+
continue;
114+
115+
if (preferredMessage is null && !IsBoilerplateUserMessage(raw))
116+
preferredMessage = sanitized;
117+
fallbackMessage ??= sanitized;
118+
}
119+
}
120+
121+
if (id is not null && cwd is not null && timestamp.HasValue && (preferredMessage is not null || fallbackMessage is not null))
122+
break;
123+
}
124+
125+
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(cwd) || !timestamp.HasValue)
126+
return false;
127+
128+
var message = preferredMessage ?? fallbackMessage ?? string.Empty;
129+
130+
info = new SessionInfo
131+
{
132+
Id = id,
133+
WorkspacePath = cwd,
134+
StartedAt = timestamp.Value,
135+
FirstUserMessage = message,
136+
RolloutPath = filePath
137+
};
138+
return true;
139+
}
140+
catch
141+
{
142+
return false;
143+
}
144+
}
145+
146+
private static IEnumerable<string> EnumerateDirectoriesDescending(string root)
147+
{
148+
try
149+
{
150+
return Directory.EnumerateDirectories(root)
151+
.OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
152+
.ToList();
153+
}
154+
catch
155+
{
156+
return Array.Empty<string>();
157+
}
158+
}
159+
160+
private static IEnumerable<string> EnumerateFilesDescending(string root, string searchPattern)
161+
{
162+
try
163+
{
164+
return Directory.EnumerateFiles(root, searchPattern, SearchOption.TopDirectoryOnly)
165+
.OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
166+
.ToList();
167+
}
168+
catch
169+
{
170+
return Array.Empty<string>();
171+
}
172+
}
173+
174+
private static List<SessionInfo> OrderByNewest(List<SessionInfo> entries)
175+
=> entries.OrderByDescending(e => e.StartedAt).ToList();
176+
177+
private static string GetSessionsRoot()
178+
{
179+
try
180+
{
181+
if (OperatingSystem.IsWindows() && WslInterop.IsEnabled)
182+
{
183+
var converted = WslInterop.TryConvertToWindowsPath("~/.codex/sessions");
184+
if (!string.IsNullOrWhiteSpace(converted))
185+
return converted;
186+
}
187+
188+
var home = Environment.GetEnvironmentVariable("CODEX_HOME");
189+
string baseDir = !string.IsNullOrWhiteSpace(home)
190+
? home!
191+
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex");
192+
return Path.Combine(baseDir, "sessions");
193+
}
194+
catch
195+
{
196+
return Path.Combine(Directory.GetCurrentDirectory(), "sessions");
197+
}
198+
}
199+
200+
private static string NormalizePath(string path)
201+
{
202+
if (string.IsNullOrWhiteSpace(path))
203+
return string.Empty;
204+
try
205+
{
206+
var full = Path.GetFullPath(path);
207+
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
208+
}
209+
catch
210+
{
211+
return path.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
212+
}
213+
}
214+
215+
private static bool PathsEqual(string a, string b)
216+
{
217+
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b))
218+
return false;
219+
var comparison = OperatingSystem.IsWindows()
220+
? StringComparison.OrdinalIgnoreCase
221+
: StringComparison.Ordinal;
222+
return string.Equals(a, b, comparison);
223+
}
224+
225+
private static string ExtractMessageText(JArray? contentArray)
226+
{
227+
if (contentArray is null)
228+
return string.Empty;
229+
var builder = new StringBuilder();
230+
foreach (var item in contentArray.OfType<JObject>())
231+
{
232+
var text = item["text"]?.ToString();
233+
if (string.IsNullOrWhiteSpace(text))
234+
continue;
235+
if (builder.Length > 0)
236+
builder.Append('\n');
237+
builder.Append(text);
238+
}
239+
return builder.ToString();
240+
}
241+
242+
private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled);
243+
private static readonly Regex WhitespaceRegex = new("\\s+", RegexOptions.Compiled);
244+
245+
private static string SanitizeUserMessage(string text)
246+
{
247+
if (string.IsNullOrWhiteSpace(text))
248+
return string.Empty;
249+
250+
var normalized = text.Replace("\r\n", "\n");
251+
normalized = TagRegex.Replace(normalized, " ");
252+
normalized = normalized.Replace('`', ' ');
253+
normalized = WhitespaceRegex.Replace(normalized, " ").Trim();
254+
return normalized;
255+
}
256+
257+
private static bool IsBoilerplateUserMessage(string raw)
258+
{
259+
if (string.IsNullOrWhiteSpace(raw))
260+
return true;
261+
var lower = raw.ToLowerInvariant();
262+
if (lower.Contains("<user_instructions") || lower.Contains("<environment_context"))
263+
return true;
264+
if (lower.Contains("## tui style") || lower.Contains("## tui code conventions"))
265+
return true;
266+
if (lower.Contains("run `just fmt`") || lower.Contains("cargo test -p"))
267+
return true;
268+
return false;
269+
}
270+
}

SemanticDeveloper/SemanticDeveloper/Views/SelectOptionDialog.axaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<Window xmlns="https://github.com/avaloniaui"
22
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:views="clr-namespace:SemanticDeveloper.Views"
34
x:Class="SemanticDeveloper.Views.SelectOptionDialog"
45
Width="520" Height="380"
56
CanResize="False"
@@ -8,7 +9,24 @@
89
Icon="avares://SemanticDeveloper/Images/semantic-developer-logo-large.png">
910
<Grid RowDefinitions="Auto,*,Auto" Margin="16" RowSpacing="12">
1011
<TextBlock x:Name="PromptText" TextWrapping="Wrap"/>
11-
<ListBox Grid.Row="1" x:Name="OptionsList" SelectionMode="Single"/>
12+
<ListBox Grid.Row="1"
13+
x:Name="OptionsList"
14+
SelectionMode="Single">
15+
<ListBox.ItemTemplate>
16+
<DataTemplate x:DataType="views:SelectOptionDialog+OptionEntry">
17+
<TextBlock Text="{Binding Display}"
18+
TextTrimming="CharacterEllipsis">
19+
<ToolTip.Tip>
20+
<Border Padding="6"
21+
MaxWidth="440">
22+
<TextBlock Text="{Binding Tooltip}"
23+
TextWrapping="Wrap"/>
24+
</Border>
25+
</ToolTip.Tip>
26+
</TextBlock>
27+
</DataTemplate>
28+
</ListBox.ItemTemplate>
29+
</ListBox>
1230
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
1331
<Button Width="96" Click="OnCancel" IsCancel="True">Cancel</Button>
1432
<Button Width="96" Click="OnOk" IsDefault="True">OK</Button>

SemanticDeveloper/SemanticDeveloper/Views/SelectOptionDialog.axaml.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ namespace SemanticDeveloper.Views;
66

77
public partial class SelectOptionDialog : Window
88
{
9+
public sealed class OptionEntry
10+
{
11+
public OptionEntry() { }
12+
public OptionEntry(string display)
13+
{
14+
Display = display;
15+
}
16+
17+
public string Display { get; set; } = string.Empty;
18+
public string Tooltip { get; set; } = string.Empty;
19+
}
20+
921
public SelectOptionDialog()
1022
{
1123
InitializeComponent();
@@ -17,8 +29,8 @@ public string Prompt
1729
set => PromptText.Text = value;
1830
}
1931

20-
private List<string> _options = new();
21-
public List<string> Options
32+
private List<OptionEntry> _options = new();
33+
public List<OptionEntry> Options
2234
{
2335
get => _options;
2436
set

SemanticDeveloper/SemanticDeveloper/Views/SessionView.axaml

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,31 @@
1111
<Grid RowDefinitions="Auto,*" ColumnDefinitions="*">
1212
<!-- Top toolbar -->
1313
<Border Background="#2B2B2B" Padding="8,6">
14-
<Grid ColumnDefinitions="Auto,12,*,Auto,8,Auto,8,Auto,12,Auto,8,Auto">
14+
<Grid ColumnDefinitions="Auto,8,Auto,12,*,Auto,8,Auto,8,Auto,12,Auto,8,Auto">
1515
<Button x:Name="SelectWorkspaceButton" Foreground="#E6E6E6" Padding="10,4" Click="OnSelectWorkspaceClick">Select Workspace…</Button>
1616
<Border Grid.Column="1"/>
17-
<TextBlock Grid.Column="2" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" Foreground="#E6E6E6" Margin="0,0,6,0">
17+
<CheckBox Grid.Column="2"
18+
x:Name="ResumeSessionToggle"
19+
Foreground="#E6E6E6"
20+
Padding="8,4"
21+
IsChecked="{Binding ResumeSessionEnabled, Mode=TwoWay}"
22+
Content="Resume Session"/>
23+
<Border Grid.Column="3"/>
24+
<TextBlock Grid.Column="4" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" Foreground="#E6E6E6" Margin="0,0,6,0">
1825
<Run Text="Workspace: "/>
1926
<Run Text="{Binding CurrentWorkspacePath}"/>
2027
</TextBlock>
21-
<TextBlock Grid.Column="3" VerticalAlignment="Center" Foreground="#E6E6E6" IsVisible="{Binding HasSelectedProfile}">
28+
<TextBlock Grid.Column="5" VerticalAlignment="Center" Foreground="#E6E6E6" IsVisible="{Binding HasSelectedProfile}">
2229
<Run Text=" • Profile: "/>
2330
<Run Text="{Binding SelectedProfile}"/>
2431
</TextBlock>
25-
<Border Grid.Column="4"/>
26-
<TextBlock Grid.Column="5" VerticalAlignment="Center" Foreground="#E6E6E6" IsVisible="{Binding IsGitRepo}">
32+
<Border Grid.Column="6"/>
33+
<TextBlock Grid.Column="7" VerticalAlignment="Center" Foreground="#E6E6E6" IsVisible="{Binding IsGitRepo}">
2734
<Run Text=" • Branch: "/>
2835
<Run Text="{Binding CurrentBranch}"/>
2936
</TextBlock>
30-
<Border Grid.Column="6"/>
31-
<Button Grid.Column="7" x:Name="GitMenuButton" Foreground="#E6E6E6" Margin="0,0,6,0" Padding="8,4" IsVisible="{Binding IsGitRepo}">Git ▾
37+
<Border Grid.Column="8"/>
38+
<Button Grid.Column="9" x:Name="GitMenuButton" Foreground="#E6E6E6" Margin="0,0,6,0" Padding="8,4" IsVisible="{Binding IsGitRepo}">Git ▾
3239
<Button.Flyout>
3340
<MenuFlyout>
3441
<MenuItem Header="Commit…" Click="OnGitCommitClick"/>
@@ -39,10 +46,10 @@
3946
</MenuFlyout>
4047
</Button.Flyout>
4148
</Button>
42-
<Button Grid.Column="7" x:Name="InitGitButton" Foreground="#E6E6E6" Padding="8,4" IsVisible="{Binding CanInitGit}" Click="OnGitInitClick">Initialize Git…</Button>
43-
<Button Grid.Column="9" x:Name="OpenInFileManagerButton" Foreground="#E6E6E6" Padding="8,4" Click="OnOpenInFileManagerClick" IsEnabled="{Binding HasWorkspace}">Open in File Manager</Button>
44-
<Border Grid.Column="10"/>
45-
<Button Grid.Column="11" x:Name="CloseSessionButton" Foreground="#E6E6E6" Padding="6,4" Click="OnCloseSessionClick">Close Tab</Button>
49+
<Button Grid.Column="9" x:Name="InitGitButton" Foreground="#E6E6E6" Padding="8,4" IsVisible="{Binding CanInitGit}" Click="OnGitInitClick">Initialize Git…</Button>
50+
<Button Grid.Column="11" x:Name="OpenInFileManagerButton" Foreground="#E6E6E6" Padding="8,4" Click="OnOpenInFileManagerClick" IsEnabled="{Binding HasWorkspace}">Open in File Manager</Button>
51+
<Border Grid.Column="12"/>
52+
<Button Grid.Column="13" x:Name="CloseSessionButton" Foreground="#E6E6E6" Padding="6,4" Click="OnCloseSessionClick">Close Tab</Button>
4653
</Grid>
4754
</Border>
4855

0 commit comments

Comments
 (0)