picker-first startup with per-project sticky override (T-115)
Boot used to auto-open app.lastProject and fall back to the CWD; new default is the welcome screen as the project picker. Sticky-open is opt-in: a checkbox on each recent-projects row toggles a startupSticky flag, and clide auto-opens iff exactly one row has it. Two-or-more, or none, ⇒ picker (unambiguous user intent). RecentProject gains the boolean (persisted in app.recentProjects); ProjectManager exposes stickyProjectPath, openStickyOrNothing, setStickyStartup, isStickyStartup, and preserves the flag across reopens. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'name': name,
|
||||
'branch': branch,
|
||||
'lastOpened': lastOpened.toIso8601String(),
|
||||
if (startupSticky) 'startupSticky': true,
|
||||
};
|
||||
|
||||
factory RecentProject.fromJson(Map<String, dynamic> 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<String>('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<bool> 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<void> 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<String>('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<void> close() async {
|
||||
if (_current == null) return;
|
||||
_current = null;
|
||||
|
||||
+4
-4
@@ -156,10 +156,10 @@ Future<void> 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');
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user