diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 103c824d..eefb89b5 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -1813,3 +1813,6 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:53:10', '2026-05-18 07:53:10', '2026-05-18 07:53:10', NULL, 'ce2b66658977edc3fddc78079d7852e5', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:02:06', '2026-05-18 08:02:06', '2026-05-18 08:02:06', NULL, 'ee75fea173ae492a6ad8a1d888a2142d', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '9dd61b76946909aa44edaf9d1c1741a0', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'status', 'backlog', 'done', NULL, '2026-05-18 08:22:19', '2026-05-18 08:22:19', '2026-05-18 08:22:19', NULL, '861af9256207314d681e51abc77c386f', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:23:34', '2026-05-18 08:23:34', '2026-05-18 08:23:34', NULL, '94f4a894c1dddfa87ee8306a9758c808', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:26:05', '2026-05-18 08:26:05', '2026-05-18 08:26:05', NULL, 'b8978359fc1b83ea0bcfc6cd5d34d5de', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index c5064dc8..8ca768b5 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -1895,3 +1895,28 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow). Source: consultants.md "Docs — Findings".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:07:23', NULL, '7c1f64a5aef6e3e92157691a2b192beb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'task', 'T-97', 'cut interim release to drain Unreleased (split from T-109)', 'Item 3 of T-109. With CONTRIBUTING.md landed and [Unreleased] consolidated, the remaining piece is cutting an actual release so the ~80-commit Unreleased backlog drains into a dated heading. + +Per .claude/skills/git-commit/SKILL.md "Cutting a release": +1. Move every [Unreleased] entry under a new `## [X.Y.Z] — YYYY-MM-DD` heading. +2. Leave an empty [Unreleased] skeleton. +3. Bump pubspec.yaml version: (drop the -dev suffix for the tag). +4. Commit subject: `release vX.Y.Z`. + +Version pick is the user''s call — likely 2.1.0 given the scope since 2.0.0 (D-66 95% coverage gate, T-117 keymap, contrast-gate split, IPC hardening, panel-keyboard parity, etc.). + +Source: consultants.md "Docs — Findings" item 3.', 'done', 'medium', NULL, NULL, NULL, '2026-05-18 08:07:23', '2026-05-18 08:22:19', NULL, '6f9f48da5d95074af1abed3df64b109a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'story', NULL, 'startup project picker; "always open this project" override', 'Startup flow change: on launch, always show the project picker screen unless the user has explicitly checked an "always open this project" box for a specific project (sticky default). + +**Acceptance:** +1. Default startup: project picker is the first screen, regardless of last-opened state. +2. Picker has an "always open this project on launch" checkbox per project entry. +3. If exactly one project has that flag set, startup skips the picker and opens that project directly. +4. The flag is per-project, persisted in app settings (probably under `app.startupProject`). +5. The checkbox can be unticked from the welcome view to restore picker-first behavior. + +**Notes:** today the app auto-opens `app.lastProject` on boot (kernel/src/project.dart `openLast`). The new flow inverts the default — picker is the steady state, sticky-open is opt-in per project. + +**Out of scope:** multi-project workspaces, project groups, recent-project ordering changes. + +**Source:** user request 2026-05-17.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 19:10:21', '2026-05-18 08:26:05', NULL, 'dc53a7ac8809206c2824d316750ec28a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 79919b4f..3532bd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Startup project picker — clide now opens to the welcome screen by + default instead of auto-opening the last project. A per-row + "always open this project on launch" checkbox in welcome's RECENT + list sets a sticky-startup flag; if exactly one project has it, + that one opens directly. Two or more, or none ⇒ picker (T-115). + ### Changed ### Deprecated diff --git a/lib/builtin/welcome/src/welcome_view.dart b/lib/builtin/welcome/src/welcome_view.dart index 7e2659db..d76d1c31 100644 --- a/lib/builtin/welcome/src/welcome_view.dart +++ b/lib/builtin/welcome/src/welcome_view.dart @@ -264,7 +264,13 @@ class _RecentColumn extends StatelessWidget { if (recents.isEmpty) const ClideText('No recent projects.', muted: true, fontSize: 14) else - for (final r in recents) _RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)), + for (final r in recents) + _RecentRow( + project: r, + tokens: tokens, + onTap: () => _openRecent(r.path), + onToggleSticky: () => kernel.project.setStickyStartup(r.path, !r.startupSticky), + ), ], ); }, @@ -279,10 +285,16 @@ class _RecentColumn extends StatelessWidget { } class _RecentRow extends StatelessWidget { - const _RecentRow({required this.project, required this.tokens, required this.onTap}); + const _RecentRow({ + required this.project, + required this.tokens, + required this.onTap, + required this.onToggleSticky, + }); final RecentProject project; final SurfaceTokens tokens; final VoidCallback onTap; + final VoidCallback onToggleSticky; @override Widget build(BuildContext context) { @@ -318,6 +330,8 @@ class _RecentRow extends StatelessWidget { ], ), ), + _StickyToggle(sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky), + const SizedBox(width: 12), ClideText(project.timeAgo, muted: true, fontSize: 13), ], ), @@ -326,6 +340,41 @@ class _RecentRow extends StatelessWidget { } } +/// Sticky-startup checkbox shown on each recent-project row (T-115). +/// When exactly one row is checked, clide opens that project on next +/// launch instead of showing the picker. Tooltip explains the rule. +class _StickyToggle extends StatelessWidget { + const _StickyToggle({required this.sticky, required this.tokens, required this.onTap}); + final bool sticky; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ClideTooltip( + message: sticky ? 'Always open this project on launch (uncheck to restore picker)' : 'Always open this project on launch', + child: Semantics( + button: true, + checked: sticky, + label: 'always open this project on launch', + child: ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: sticky ? tokens.statusBarItemActiveBackground : null, + border: Border.all(color: hovered || sticky ? tokens.panelActiveBorder : tokens.globalBorder), + borderRadius: BorderRadius.circular(3), + ), + child: sticky ? ClideIcon(PhosphorIcons.check, size: 12, color: tokens.buttonForeground) : null, + ), + ), + ), + ); + } +} + class _StatusLine extends StatelessWidget { const _StatusLine({required this.tokens, required this.kernel}); final SurfaceTokens tokens; diff --git a/lib/kernel/src/project.dart b/lib/kernel/src/project.dart index 10da5742..4738a0a9 100644 --- a/lib/kernel/src/project.dart +++ b/lib/kernel/src/project.dart @@ -9,20 +9,46 @@ import 'package:clide/kernel/src/toolchain.dart'; import 'package:flutter/foundation.dart'; class RecentProject { - const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened}); + const RecentProject({ + required this.path, + required this.name, + this.branch, + required this.lastOpened, + this.startupSticky = false, + }); final String path; final String name; final String? branch; final DateTime lastOpened; - Map toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()}; + /// When set, this project is preferred on startup over the picker. + /// If exactly one recent has this flag, [ProjectManager.openStickyOrNothing] + /// opens it directly; otherwise the welcome screen takes over (T-115). + final bool startupSticky; + + RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject( + path: path, + name: name, + branch: branch ?? this.branch, + lastOpened: lastOpened ?? this.lastOpened, + startupSticky: startupSticky ?? this.startupSticky, + ); + + Map toJson() => { + 'path': path, + 'name': name, + 'branch': branch, + 'lastOpened': lastOpened.toIso8601String(), + if (startupSticky) 'startupSticky': true, + }; factory RecentProject.fromJson(Map json) => RecentProject( path: json['path'] as String? ?? '', name: json['name'] as String? ?? '', branch: json['branch'] as String?, lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(), + startupSticky: json['startupSticky'] as bool? ?? false, ); String get relativePath { @@ -104,8 +130,15 @@ class ProjectManager extends ChangeNotifier { final branch = await _currentBranch(root); final name = root.split('/').last; + // Preserve the sticky-startup flag across reopens — otherwise the + // user would have to re-toggle it every time they touched the + // project (T-115). + final wasSticky = isStickyStartup(root); _recents.removeWhere((r) => r.path == root); - _recents.insert(0, RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now())); + _recents.insert( + 0, + RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now(), startupSticky: wasSticky), + ); if (_recents.length > 10) _recents = _recents.sublist(0, 10); await _settings.set('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList())); @@ -122,6 +155,42 @@ class ProjectManager extends ChangeNotifier { return open(last); } + /// Path of the lone sticky-startup project, or null if zero or + /// multiple recents have the flag. Zero ⇒ show the picker; multiple + /// ⇒ ambiguous, also show the picker (T-115). + String? get stickyProjectPath { + final sticky = _recents.where((r) => r.startupSticky).toList(); + return sticky.length == 1 ? sticky.first.path : null; + } + + /// Open the single sticky-startup project, if any. Returns false on + /// "no unambiguous sticky" so the caller (main.dart) can fall through + /// to the welcome screen. + Future openStickyOrNothing() async { + final path = stickyProjectPath; + if (path == null) return false; + final dir = Directory(path); + if (!await dir.exists()) return false; + return open(path); + } + + /// Toggle the per-project sticky-startup flag and persist. No-op if + /// the path isn't in recents (open the project first to land it + /// there). + Future setStickyStartup(String path, bool value) async { + final idx = _recents.indexWhere((r) => r.path == path); + if (idx < 0) return; + if (_recents[idx].startupSticky == value) return; + _recents[idx] = _recents[idx].copyWith(startupSticky: value); + await _settings.set('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList())); + notifyListeners(); + } + + bool isStickyStartup(String path) { + final idx = _recents.indexWhere((r) => r.path == path); + return idx >= 0 && _recents[idx].startupSticky; + } + Future close() async { if (_current == null) return; _current = null; diff --git a/lib/main.dart b/lib/main.dart index 786a593e..89b404ce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -156,10 +156,10 @@ Future main() async { if (!kIsWeb) { await services.project.loadRecents(); - var opened = await services.project.openLast(); - if (!opened) { - opened = await services.project.open(Directory.current.path); - } + // T-115: picker-first. Auto-open only when exactly one recent has + // its sticky-startup flag set; otherwise the welcome tab (default + // workspace content) serves as the project picker. + final opened = await services.project.openStickyOrNothing(); if (opened) { services.panels.activateTab(Slots.workspace, 'claude.primary'); } diff --git a/test/kernel/src/project_test.dart b/test/kernel/src/project_test.dart index 5b2c6f5e..08695c9b 100644 Binary files a/test/kernel/src/project_test.dart and b/test/kernel/src/project_test.dart differ