dock Claude's task list above the composer (T-308)
Claude's TodoWrite checklist was invisible. Add a TaskItem/TaskStatus model + a latest-wins parser (taskListFrom) that reads the most recent TodoWrite tool call (it replaces the whole list each time), and a compact display-only ClaudeTaskDock pinned between the conversation and the composer: collapsed to "N tasks · M done" + the current in-progress item, expandable to the full checklist with per-item status glyphs + a11y labels. Hidden when there are no tasks. Parser + widget tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3172,3 +3172,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'ready', 'backlog', NULL, '2026-06-10 12:08:18', '2026-06-10 12:08:18', '2026-06-10 12:08:18', NULL, '12eee2948e8f79732b40384a40429e14', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:12:30', '2026-06-10 12:12:30', '2026-06-10 12:12:30', NULL, '6b9aaf9bd7ec3ad4ad3b95d1468ff9cf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'status', 'ready', 'done', NULL, '2026-06-10 12:14:00', '2026-06-10 12:14:00', '2026-06-10 12:14:00', NULL, 'd90deec4e27b4298c6f7280338599c01', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'ready', 'done', NULL, '2026-06-10 12:31:23', '2026-06-10 12:31:23', '2026-06-10 12:31:23', NULL, 'dbac53eef0b8d58dbb5f00a2f77ee52f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2361,3 +2361,21 @@ ACCEPTANCE
|
||||
- Widget test covers the new button resolving to a DenyTool with the expected note.
|
||||
|
||||
CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 12:14:00', NULL, '995bc6dbbc7231306303b838aba8a2eb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is.
|
||||
|
||||
Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works.
|
||||
|
||||
Scope:
|
||||
- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log.
|
||||
- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks.
|
||||
- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control.
|
||||
- a11y: list semantics, status announced per item.
|
||||
|
||||
Acceptance:
|
||||
1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state.
|
||||
2. Empty/no-task sessions show nothing (no empty chrome).
|
||||
3. Collapsed by default with a one-line summary; expandable to the full list.
|
||||
4. Display-only; the composer keeps full function and focus behaviour.
|
||||
5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty.
|
||||
|
||||
Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 12:31:23', NULL, 'ddfb6e694a6c5b6e3bc39132cd57318c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude's task list is now visible, docked above the composer.** When Claude
|
||||
is tracking a TodoWrite checklist, a compact display-only strip shows it pinned
|
||||
above the input — collapsed to `N tasks · M done` + the current in-progress
|
||||
item, expandable to the full list with per-item status glyphs. Hidden when
|
||||
there are no tasks. (T-308)
|
||||
- **A "Deny & simplify" option on the permission card.** Alongside Allow /
|
||||
Allow-and-remember / Deny, a fourth button denies the action with a
|
||||
preformatted note telling Claude it was too complex for the permission system
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'claude_banner.dart';
|
||||
import 'claude_composer.dart';
|
||||
import 'claude_config.dart';
|
||||
import 'claude_status.dart';
|
||||
import 'claude_task_dock.dart';
|
||||
import 'clipboard_paste.dart';
|
||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||
import 'conversation_controller.dart';
|
||||
@@ -21,6 +22,7 @@ import 'session_orchestrator.dart';
|
||||
import 'session_picker.dart';
|
||||
import 'slash_commands.dart';
|
||||
import 'stream_json_session.dart';
|
||||
import 'task_list.dart';
|
||||
import 'transcript_reader.dart';
|
||||
|
||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||
@@ -513,6 +515,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Claude's task list, docked above the composer (T-308). Rebuilds
|
||||
// with the conversation; renders nothing when there are no tasks.
|
||||
ListenableBuilder(
|
||||
listenable: _conversation!,
|
||||
builder: (_, __) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
|
||||
),
|
||||
// An open prompt takes the composer's space and hides the text
|
||||
// input until it's answered, so interaction stays out of the
|
||||
// conversation stream (D-78).
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/// Claude's task list, docked above the composer (T-308).
|
||||
///
|
||||
/// A compact, display-only surface (D-78 — not an interactive control) pinned
|
||||
/// between the conversation and the composer so the user can always see what
|
||||
/// Claude is tracking and how far along it is. Collapsed by default to a
|
||||
/// one-line summary (`N tasks · M done` + the current in-progress item);
|
||||
/// tapping expands the full checklist. Renders nothing when there are no tasks.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClaudeTaskDock extends StatefulWidget {
|
||||
const ClaudeTaskDock({super.key, required this.tasks});
|
||||
|
||||
final List<TaskItem> tasks;
|
||||
|
||||
@override
|
||||
State<ClaudeTaskDock> createState() => _ClaudeTaskDockState();
|
||||
}
|
||||
|
||||
class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasks = widget.tasks;
|
||||
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
|
||||
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
|
||||
final current = inProgress.isEmpty ? null : inProgress.first.text;
|
||||
final summary = '${tasks.length} task${tasks.length == 1 ? '' : 's'} · $done done';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
|
||||
child: ClideTappable(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
tooltip: _expanded ? 'Collapse tasks' : 'Expand tasks',
|
||||
builder: (context, hovered, focused) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The summary row IS the toggle — a single labelled button node
|
||||
// (its inner text is announced via the label, so exclude it).
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Claude task list, $summary, ${_expanded ? 'expanded' : 'collapsed'}',
|
||||
excludeSemantics: true,
|
||||
child: _summaryRow(tokens, summary, current),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
ClideDivider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [for (final t in tasks) _taskRow(tokens, t)],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
if (!_expanded && current != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
] else
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _taskRow(SurfaceTokens tokens, TaskItem t) {
|
||||
final (String glyph, Color color, String word) = switch (t.status) {
|
||||
TaskStatus.completed => ('check-circle', tokens.statusSuccess, 'done'),
|
||||
TaskStatus.inProgress => ('circle-half', tokens.globalFocus, 'in progress'),
|
||||
TaskStatus.pending => ('circle', tokens.globalTextMuted, 'pending'),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Semantics(
|
||||
label: '${t.text}, $word',
|
||||
container: true,
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.only(top: 1), child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
t.text,
|
||||
fontSize: clideFontCaption,
|
||||
color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/// Claude's working task list, modelled from the conversation for the docked
|
||||
/// task view (T-308).
|
||||
///
|
||||
/// Claude tracks tasks with the `TodoWrite` tool, which **replaces the whole
|
||||
/// list** on each call — so the current state is simply the todos of the most
|
||||
/// recent `TodoWrite`. This is a latest-wins snapshot, not an append log; older
|
||||
/// `TodoWrite` calls are superseded.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
|
||||
enum TaskStatus { pending, inProgress, completed }
|
||||
|
||||
class TaskItem {
|
||||
const TaskItem({required this.text, required this.status});
|
||||
|
||||
final String text;
|
||||
final TaskStatus status;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is TaskItem && other.text == text && other.status == status;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(text, status);
|
||||
}
|
||||
|
||||
/// The current task list — the todos of the most recent `TodoWrite` tool call,
|
||||
/// or empty if Claude hasn't written one this session.
|
||||
List<TaskItem> taskListFrom(List<ConversationItem> items) {
|
||||
for (var i = items.length - 1; i >= 0; i--) {
|
||||
final it = items[i];
|
||||
if (it is! AssistantToolUse || it.name != 'TodoWrite') continue;
|
||||
final raw = it.input['todos'];
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final t in raw)
|
||||
if (t is Map)
|
||||
TaskItem(
|
||||
// `content` is the canonical label; `activeForm` is the present-tense
|
||||
// variant TodoWrite also carries — fall back to it, then to empty.
|
||||
text: (t['content'] ?? t['activeForm'] ?? '').toString(),
|
||||
status: _statusFrom(t['status']),
|
||||
),
|
||||
];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
TaskStatus _statusFrom(Object? raw) => switch (raw) {
|
||||
'in_progress' => TaskStatus.inProgress,
|
||||
'completed' => TaskStatus.completed,
|
||||
_ => TaskStatus.pending,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/// Widget coverage for the docked task list (T-308): hidden when empty,
|
||||
/// collapsed summary with the current in-progress item, expand/collapse, and
|
||||
/// per-item status semantics.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_task_dock.dart';
|
||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
const tasks = [
|
||||
TaskItem(text: 'wire the dock', status: TaskStatus.completed),
|
||||
TaskItem(text: 'render the rows', status: TaskStatus.inProgress),
|
||||
TaskItem(text: 'write the tests', status: TaskStatus.pending),
|
||||
];
|
||||
|
||||
Future<void> pump(WidgetTester tester, List<TaskItem> items) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Align(alignment: Alignment.topLeft, child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items))),
|
||||
));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('renders nothing when there are no tasks', (tester) async {
|
||||
await pump(tester, const []);
|
||||
expect(find.byType(ClideText), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('collapsed: a one-line summary + the current in-progress item; rows hidden', (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await pump(tester, tasks);
|
||||
expect(find.text('3 tasks · 1 done'), findsOneWidget);
|
||||
expect(find.text('render the rows'), findsOneWidget); // current in-progress in the summary
|
||||
// The other rows aren't shown while collapsed.
|
||||
expect(find.text('wire the dock'), findsNothing);
|
||||
expect(find.text('write the tests'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Claude task list, 3 tasks · 1 done, collapsed'), findsOneWidget);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('tapping expands to the full checklist with per-item status', (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await pump(tester, tasks);
|
||||
await tester.tap(find.bySemanticsLabel('Claude task list, 3 tasks · 1 done, collapsed'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('wire the dock'), findsOneWidget);
|
||||
expect(find.text('render the rows'), findsOneWidget);
|
||||
expect(find.text('write the tests'), findsOneWidget);
|
||||
// Per-item status announced for AT.
|
||||
expect(find.bySemanticsLabel('wire the dock, done'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('render the rows, in progress'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('write the tests, pending'), findsOneWidget);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('singular task count reads "1 task"', (tester) async {
|
||||
await pump(tester, const [TaskItem(text: 'lonely', status: TaskStatus.pending)]);
|
||||
expect(find.text('1 task · 0 done'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/// Parser for Claude's TodoWrite task list (T-308): latest-wins, status
|
||||
/// mapping, content/activeForm fallback, and graceful handling of non-TodoWrite
|
||||
/// or malformed input.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
final _t = DateTime.utc(2026);
|
||||
|
||||
AssistantToolUse _todo(Object todos, {String id = 'x'}) =>
|
||||
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'TodoWrite', input: {'todos': todos});
|
||||
|
||||
void main() {
|
||||
test('parses todos with their status', () {
|
||||
final tasks = taskListFrom([
|
||||
_todo([
|
||||
{'content': 'a', 'status': 'pending'},
|
||||
{'content': 'b', 'status': 'in_progress'},
|
||||
{'content': 'c', 'status': 'completed'},
|
||||
])
|
||||
]);
|
||||
expect(tasks, const [
|
||||
TaskItem(text: 'a', status: TaskStatus.pending),
|
||||
TaskItem(text: 'b', status: TaskStatus.inProgress),
|
||||
TaskItem(text: 'c', status: TaskStatus.completed),
|
||||
]);
|
||||
});
|
||||
|
||||
test('the latest TodoWrite wins — a snapshot, not an append log', () {
|
||||
final tasks = taskListFrom([
|
||||
_todo([
|
||||
{'content': 'old', 'status': 'pending'}
|
||||
], id: '1'),
|
||||
_todo([
|
||||
{'content': 'new', 'status': 'in_progress'}
|
||||
], id: '2'),
|
||||
]);
|
||||
expect(tasks, const [TaskItem(text: 'new', status: TaskStatus.inProgress)]);
|
||||
});
|
||||
|
||||
test('content falls back to activeForm then empty; unknown status → pending', () {
|
||||
final tasks = taskListFrom([
|
||||
_todo([
|
||||
{'activeForm': 'doing it', 'status': 'in_progress'},
|
||||
{'status': 'weird'},
|
||||
])
|
||||
]);
|
||||
expect(tasks[0].text, 'doing it');
|
||||
expect(tasks[1].text, '');
|
||||
expect(tasks[1].status, TaskStatus.pending);
|
||||
});
|
||||
|
||||
test('no TodoWrite → empty', () {
|
||||
expect(taskListFrom(const []), isEmpty);
|
||||
expect(
|
||||
taskListFrom([
|
||||
AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'})
|
||||
]),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed todos (not a list) → empty', () {
|
||||
expect(taskListFrom([_todo('nope')]), isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user